view src/DML/MainVisBundle/Resources/assets/marionette/modules/DataModule/DataModule.10-CliopatriaAPI.js @ 0:493bcb69166c

added public content
author Daniel Wolff
date Tue, 09 Feb 2016 20:54:02 +0100
parents
children
line wrap: on
line source
"use strict";
/**
 * The module wraps all API calls to the data server and abstracts JSONP calls.
 *
 * API spec: https://code.soundsoftware.ac.uk/projects/dml/wiki/Vis_API_-_front_end_~_data_server
 *
 *
 * Module options:
 *
 *   apiRootPaths
 *      A pool of URLs from where the data are obtained. Multiple entry points (domains) can be used.
 *      The choice of a particular root path is "random", i.e. this is a "hash" function of a query contents:
 *          "doThis?x=1&y=1"   -> path 1 of 3
 *          "doThat?x=1&y=1"   -> path 3
 *          "doThat?x=1&y=123" -> path 1
 *          "doThat?x=1&y=2"   -> path 2
 *          ...
 *          "doThis?x=1&y=1"   -> path 1
 *
 *      The same query is always requested from the same instance (domain) to facilitate HTTP caching.
 *      // instead of http:// and https:// can be used for protocol auto-selection.
 *      Trailing slash is compulsory.
 *
 *      Examples:
 *          ["//127.0.0.1/"],
 *          ["//127.0.0.1/my-path/to/dml-api/"],
 *          ["//a.example.com/dml-api/", "//b.example.com/dml-api/", "//c.example.com/dml-api/"],
 *
 *   apiVersion
 *      A natural number representing the version of the data API (i.e. the available features of the interface)
 *
 *      Examples:
 *          1
 *          42
 *
 *   dataVersion
 *      A natural number or a string representing the version of the selected music library.
 *      As API calls are supplemented with dv=[dataVersion], a change in the value of dataVersion drops the HTTP cache.
 *      dataVersion is delivered to the front end with the contents of the web page.
 *
 *      Examples:
 *          42
 *          "2015-01-01"
 *
 */
App.module("DataModule.CliopatriaAPI", function(CliopatriaAPI, App, Backbone, Marionette, $, _, Logger) {
    // Prevent auto start
    CliopatriaAPI.startWithParent = false;

    // Define module options
    var defaultModuleOptions = {
            // if a request is not returned in this number of ms, handle it as failed
            callbackTimeout: 30000,

            // expired requests are being checked this often (ms)
            callbackTimeoutCheckingInterval: 1000,

            // callback of JSONP responses
            callbackFunctionName: "jsonp_cb",

            // optional data version suffix that is appended to all API requests, e.g. "123" → &dv=123
            dataVersion: null,

            apiRootPaths: [],

            apiVersion: 1,

            dataCaching: true
    };

    // =========================================================================
    // private variables
    var logger = null;

    /**
     * derived from CliopatriaAPI.options.dataVersion
     */
    var dataVersionSuffix = null;

    /**
     * Real API root paths include API version (e.g. http://example.com/path/to/api/v42/)
     */
    var realAPIRootPaths = null;

    /**
     * The number of the real API paths
     */
    var realAPIRootPathsCount = null;


    /**
     * query pool: an object where keys correspond to sent queries (methodName?param1=1&...&dv=42) and values are:
     *    [timeSent, methodName, parameters, callbacks, scriptElement, query]
     */
    var queryPool = null;

    /**
     * Generates query from API method name and parameters (+ appends data version)
     * Parameters are sorted alphabetically, except dv, which is always the last
     */
    var generateQuery = null;

    /**
     * a function that is called every callbackTimeoutCheckingInterval ms and checks for expired query pool entries
     */
    var callbackTimeoutExpiryChecker = null;

    /**
     * timeout (interval) handler for callbackTimeoutExpiryChecker
     */
    var callbackTimeoutExpiryCheckerId = null;

    /**
     * Initialization checker
     */
    var assertModuleIsInitialized = function() {
        if (!logger) {
            throw "DataModule.CliopatriaAPI has not been initialized";
        }
    };

    /**
     * Module initializer
     *
     */
    CliopatriaAPI.addInitializer(function(options) {

        CliopatriaAPI.options = _.extend(defaultModuleOptions, options);

        logger = Logger.get("DataModule.CliopatriaAPI");
        //logger.setLevel(Logger.DEBUG);
        //logger.setLevel(Logger.INFO);

        // Set data version suffix (common for all requests)
        if (CliopatriaAPI.options.dataVersion) {
            //dataVersionSuffix = _.str.sprintf("&dv=%s", encodeURIComponent(CliopatriaAPI.options.dataVersion));
            dataVersionSuffix = _.str.sprintf("&format=jsonp&dv=%s", encodeURIComponent(CliopatriaAPI.options.dataVersion));
        } else {
            //dataVersionSuffix = "";
            dataVersionSuffix = "&format=jsonp";
        }

        realAPIRootPaths = [];
        for (var i = 0; i < CliopatriaAPI.options.apiRootPaths.length; ++i) {
            var apiRootPath = CliopatriaAPI.options.apiRootPaths[i];

            realAPIRootPaths.push(_.str.sprintf("%sv%s/", apiRootPath, CliopatriaAPI.options.apiVersion));
        }
        realAPIRootPathsCount = realAPIRootPaths.length;

        if (!realAPIRootPathsCount) {
            logger.error("DataModule.CliopatriaAPI has no registered API paths");
        }

        queryPool = {};

        // A function that generates a query by the method name and the parameters
        generateQuery = function(methodName, parameters) {
            // Sort parameters
            var sortedParameterPairs = [];
            for (var parameterName in parameters) {
                sortedParameterPairs.push([parameterName, parameters[parameterName]]);
            }
            sortedParameterPairs.sort(function(a, b) {return _.str.naturalCmp(a[0], b[0]);});

            // encode them
            var encodedParametersAsArrayOfStrings = [];
            for (var parameterPairIndex in sortedParameterPairs) {
                encodedParametersAsArrayOfStrings.push(encodeURIComponent(sortedParameterPairs[parameterPairIndex][0]) + "=" + encodeURIComponent(sortedParameterPairs[parameterPairIndex][1]));
            };
            if (!CliopatriaAPI.options.dataCaching) {
                encodedParametersAsArrayOfStrings.push("random="+Math.round(Math.random() * 100000));
            }
            var encodedParameters = encodedParametersAsArrayOfStrings.join("&");

            // append data version (dv)
            if (methodName == "getCollectionId") {
                if (encodedParameters.length) {
                    encodedParameters += dataVersionSuffix;
                } else {
                    encodedParameters += dataVersionSuffix.slice(1);
                }
            }

            return methodName + "?" + encodedParameters;
        };

        // A function that handles API responses (successful JSONPs)
        window[CliopatriaAPI.options.callbackFunctionName] = function(data) {

            logger.debug("Callback function called with data:", data);

            // Look for an entry in the queue pool
            //// If the API server says that the query had %aa instead of expected %AA,
            //// fix the quoted query in the response to match a query that waits in the query pool
            var queryInData = data.query.replace(/%[a-z0-9]{2}/g, function(v) {return v.toUpperCase();});
            // Delete all stuff that prepends the actual API query (path including the version)
            queryInData = queryInData.replace(/^.*\/v[\d]\//, "");
            var queryPoolEntry = queryPool[queryInData];

            if (!queryPoolEntry) {
                logger.warn(_.str.sprintf("No data API pool entry found for query %s. Response was wasted.", queryInData), data);
                return;
            }

            // Remove the entry from the pool
            delete queryPool[queryInData];

            // Remove the correspondent JSONP DOM node in order to be able to make the same request again
            $(queryPoolEntry[4]).remove();
            // Execute all attached callbacks
            executeAllCallbacksOfTheQueryPoolEntry(queryPoolEntry, data.result);
        };

        // Executes all callbacks on request success or error (timeout)
        var executeAllCallbacksOfTheQueryPoolEntry = function(queryPoolEntry, result) {
            logger.info(_.str.sprintf("Executing %d callback%s for request %s", queryPoolEntry[3].length, queryPoolEntry[3].length != 1 ? "s" : "", queryPoolEntry[5]), result);
            for (var i = 0; i < queryPoolEntry[3].length; i++) {
                var callback = queryPoolEntry[3][i];
                if (_.isFunction(callback)) {
                    try {
                        callback.call(null, result, queryPoolEntry[5], queryPoolEntry[1], queryPoolEntry[2]);
                    } catch (e) {
                        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);
                    }
                }
            }
        };

        // A function that checks expired query pool entries
        callbackTimeoutExpiryChecker = function() {

            var currentTimestamp = new Date().getTime();

            for(var query in queryPool) {
                var queryPoolEntry = queryPool[query];
                logger.debug(_.str.sprintf("Checking callback timeouts: timestamp diff = %d ms for query %s", currentTimestamp - queryPoolEntry[0], query));
                if (currentTimestamp - queryPoolEntry[0] > CliopatriaAPI.options.callbackTimeout) {
                    delete queryPool[query];
                    $(queryPoolEntry[4]).remove();
                    executeAllCallbacksOfTheQueryPoolEntry(queryPoolEntry, {
                        errors: [
                                 {
                                     "code": 100,
                                     "message": "API request failed"
                                 }
                             ]
                    });
                }
            }
        };

        callbackTimeoutExpiryCheckerId = setInterval(callbackTimeoutExpiryChecker, CliopatriaAPI.options.callbackTimeoutCheckingInterval);
    });


    /**
     * Sends a request to the data API
     *
     * @param {string} methodName
     *          API method name (function)
     *
     * @param {object} parameters
     *          unsorted list of request parameters ({string} key => {string|number} value})
     *
     * @param {function} callback
     *          a function to be executed on success or failure
     *
     *          callback function arguments:
     *              {string} result
     *              {string} query (HTTP GET request)
     *              {string} requestedMethodName
     *              {object} requestedParameters
     *
     * //@return {void}
     * //         nothing, but this may be replaced with a promise in future
     *
     * @return {string}
     *          a uri that has been called
     */
    CliopatriaAPI.request = function(methodName, parameters, callback) {
        assertModuleIsInitialized();

        var query = generateQuery(methodName, parameters);

        // Choose which of the alternative API paths to use
        var realAPIRootPath;
        if (realAPIRootPathsCount == 1) {
            realAPIRootPath = realAPIRootPaths[0];
        } else {
            var sumOfCharCodes = 0;
            for (var i = 0; i < query.length; i++) {
                sumOfCharCodes += query.charCodeAt(i);
            }
            realAPIRootPath = realAPIRootPaths[sumOfCharCodes % realAPIRootPathsCount];
        }
        if (!realAPIRootPath) {
            throw _.str.sprintf("API requsest to Cliopatria cannot be made – api root path is undefined");
        }

        var requestURI = realAPIRootPath + query;

        var queryPoolEntry = queryPool[query];

        if (!queryPoolEntry) {
            // If a request with the same parameters is not in the current queue, send it

            // Prepare a real jsonp request
            logger.info("Sending request", query);
            var scriptElement = document.createElement('script');
            scriptElement.type = 'text/javascript';
            scriptElement.async = true;
            scriptElement.src = requestURI;

            // Create an entry in the query pool
            queryPoolEntry = [new Date().getTime(), methodName, parameters, [callback], scriptElement, query];
            queryPool[query] = queryPoolEntry;
            logger.debug("New entry in queryPool: ", query/*, queryPoolEntry*/);

            // Start the request
            document.getElementsByTagName('body')[0].appendChild(scriptElement);

        } else {
            // If an identical request has been called previously, don't initiate a new query,
            // simply add a callback to the list of callbacks
            logger.info("request (imaginary)", query);


            //queryPoolEntry[0] = new Date().getTime();
            queryPoolEntry[3].push(callback);
        }
        return requestURI;
    };

    /**
     * Module finalizer
     */
    CliopatriaAPI.on("stop", function(){
        clearInterval(callbackTimeoutExpiryCheckerId);
      });

}, Logger);