diff --git a/composer.json b/composer.json index 3185b6a..c113acb 100644 --- a/composer.json +++ b/composer.json @@ -27,8 +27,7 @@ "ext-ctype": "*", "ext-json": "*", "mustache/mustache": "^2.13", - "tedivm/jshrink": "~1.0", - "51degrees/fiftyone.pipeline.javascript-templates": "1.*" + "tedivm/jshrink": "~1.0" }, "require-dev": { "kint-php/kint": "^3.3", @@ -43,12 +42,5 @@ "psr-4": { "fiftyone\\pipeline\\core\\tests\\": "tests/" } - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/51Degrees/javascript-templates", - "no-api": true - } - ] + } } diff --git a/javascript-templates/JavaScriptResource.mustache b/javascript-templates/JavaScriptResource.mustache new file mode 100644 index 0000000..625a930 --- /dev/null +++ b/javascript-templates/JavaScriptResource.mustache @@ -0,0 +1,601 @@ +fiftyoneDegreesManager = function() { + 'use-strict'; + var json = {{&_jsonObject}}; + var parameters = {{&_parameters}}; + var sessionId = "{{&_sessionId}}"; + var sessionKey = "{{_objName}}_" + sessionId; + this.sessionId = sessionId; + var sequence = {{&_sequence}}; + + // Log any errors returned in the JSON object. + if(json.error !== undefined){ + console.log(json.error); + } + + // Log any warnings returned in the JSON object. + if (json.warnings !== undefined) { + console.log(json.warnings); + } + + // Set to true when the JSON object is complete. + var completed = false; + + // changeFuncs is an array of functions. When onChange is called and passed + // a function, the function is registered and is called when processing is + // complete. + var changeFuncs = []; + + // Counter is used to count how many pieces of callbacks are expected. Every + // time the completedCallback method is called, the counter is decremented + // by 1. + var callbackCounter = 0; + + // Array of JavaScript properties that have started evaluation. + var jsPropertiesStarted = []; + + // startsWith polyfill. + var startsWith = function(source, searchValue) { + return source.lastIndexOf(searchValue, 0) === 0; + } + + // endsWith polyfill. + var endsWith = function(source, searchValue) { + return source.substring(source.length - searchValue.length, source.length) === searchValue; + } + + var clearCache = function() { + if (sessionStorage) { + for (i = 0; i < sessionStorage.length; i++) { + key = sessionStorage.key(i); + if (startsWith(key, sessionKey)) { + sessionStorage.removeItem(key); + } + } + } + } + + var loadParameters = function() { + if (sessionStorage) { + var parametersString = sessionStorage.getItem(sessionKey + "_parameters"); + if (parametersString) { + parameters = JSON.parse(parametersString); + } + } + return parameters; + } + + var saveParameters = function(sourceParams) { + if (sourceParams) { + parameters = sourceParams + } + + if (sessionStorage) { + var parametersString = JSON.stringify(parameters); + sessionStorage.setItem(sessionKey + "_parameters", parametersString); + } + } + + // Get cookies with the '51D_' prefix that have been added to the request + // and return the data as key value pairs. This method is needed to extract + // cookie values for inclusion in the GET or POST request for situations + // where CORS will prevent cookies being sent to third parties. + var getFodCookies = function(){ + var keyValuePairs = document.cookie.split(/; */); + var fodCookies = []; + for(var i = 0; i < keyValuePairs.length; i++) { + var name = keyValuePairs[i].substring(0, keyValuePairs[i].indexOf('=')); + if(startsWith(name, "51D_")){ + var value = keyValuePairs[i].substring(keyValuePairs[i].indexOf('=')+1); + fodCookies[name] = value; + } + } + return fodCookies; + }; + + // Extract key value pairs from the '51D_' prefixed cookies and concatenates + // them to form a query string for the subsequent json refresh. + var getParametersFromCookies = function(){ + var fodCookies = getFodCookies(); + var keyValuePairs = []; + for (var key in fodCookies) { + if (fodCookies.hasOwnProperty(key)) { + // Url encode the cookie value. + // This is done to ensure that invalid characters (e.g. = chars at the end of + // base 64 encoded strings) reach the server intact. + // The server will automatically decode the value before passing it into the + // Pipeline API. + keyValuePairs.push(key+"="+encodeURIComponent(fodCookies[key])); + } + } + return keyValuePairs; + }; + + // Delete a cookie. + function deleteCookie(name) { + document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + } + + // Fetch a value safely from the json object. If a key somewhere down the + // '.' separated hierarchy of keys is not present then 'undefined' is + // returned rather than letting an exception occur. + var getFromJson = function(key, allowObjects, allowBooleans) { + var result = undefined; + if(typeof allowObjects === 'undefined') { allowObjects = false; } + if(typeof allowBooleans === 'undefined') { allowBooleans = false; } + + if (typeof(key) === 'string') { + var functions = json; + var segments = key.split('.'); + var i = 0; + while (functions !== undefined && i < segments.length) { + functions = functions[segments[i++]]; + } + if (typeof(functions) === "string") { + result = functions; + } else if (allowBooleans && typeof(functions) === "boolean") { + result = functions; + } else if (allowObjects && typeof functions === 'object' && functions !== null) { + result = functions; + } + } + return result; + } + + // Executed at the end of the processJSproperties method or for each piece + // of JavaScript which has 51D code injected. When there are 0 pieces of + // JavaScript left to process then reload the JSON object. + var completedCallback = function(resolve, reject){ + callbackCounter--; + if (callbackCounter === 0) { +{{#_updateEnabled}} + processRequest(resolve, reject); +{{/_updateEnabled}} + } else if (callbackCounter < 0){ + reject('Too many callbacks.'); + } + } + + // Executes any JavaScript contained in the JSON data. Session storage is + // used to check the process state of the JavaScript property, if the name + // of the property exists as a key then it has been processed. If all the + // processed JavaScript properties have been flagged as processed already + // then session storage is checked again for a JSON payload. If it exists + // then this is loaded into the managers internal data store. If not or if + // JavaScript properties have been processed then the call-back is + // processed with any new evidence produced by the JavaScript properties. + // If JavaScript properties are processed then a key containing the name of + // the JavaScript property is added to session storage. The complete flag is + // set to true when there is no further JavaScript to be processed. + var processJsProperties = function(resolve, reject, jsProperties, ignoreDelayFlag) { + var executeCallback = true; + var started = 0; + var cached = 0; + var cachedResponse = undefined; + var toProcess = 0; + + // If there is no cached response and there are JavaScript code snippets + // then process them and perform any call-backs required. + if (jsProperties !== undefined && jsProperties.length > 0) { + + // Execute each of the JavaScript property code snippets using the + // index of the value to access the value to avoid problems with + // JavaScript returning erroneous values. + for (var index = 0; index < jsProperties.length; index++) { + var name = jsProperties[index]; + if (jsPropertiesStarted.indexOf(name) === -1) { + var body = getFromJson(name); + + // If there is a body then this property should be processed. + if (body) { + toProcess++; + } + var isCached = sessionStorage && sessionStorage.getItem(sessionKey + "_" + name); + + if (!isCached) { + // Create new function bound to this instance and execute it. + // This is needed to ensure the scope of the function is + // associated with this instance if any members are altered or + // added. Avoids global scoped variables. + + var delay = getFromJson(name + 'delayexecution', false, true); + + if ((ignoreDelayFlag || (delay === undefined || delay === false)) && + body !== undefined) { + var func = undefined; + var searchString = '// 51D replace this comment with callback function.'; + completed = false; + jsPropertiesStarted.push(name); + started++; + + if (body.indexOf(searchString) !== -1){ + callbackCounter++; + body = body.replace(/\/\/ 51D replace this comment with callback function./g, 'callbackFunc(resolveFunc, rejectFunc);'); + func = new Function('callbackFunc', 'resolveFunc', 'rejectFunc', + "try {\n" + + body + "\n" + + "} catch (err) {\n" + + "console.log(err);" + + "}" + ); + func(completedCallback, resolve, reject); + executeCallback = false; + } else { + func = new Function( + "try {\n" + + body + "\n" + + "} catch (err) {\n" + + "console.log(err);" + + "}" + ); + func(); + } + if (sessionStorage) { + sessionStorage.setItem(sessionKey + "_" + name, true) + } + } + } else { + cached++; + } + } + } + } + if(cached === toProcess || started === 0) { + if (sessionStorage) { + var cachedResponse = sessionStorage.getItem(sessionKey); + if (cachedResponse) { + loadJSON(resolve, reject, cachedResponse); + executeCallback = false; + } + } + } + + if (started === 0) { + executeCallback = false; + completed = true; + } + if (executeCallback) { + callbackCounter = 1; + completedCallback(resolve, reject); + } + }; + +{{#_updateEnabled}} +{{^_supportsFetch}} + // Standard method to create a CORS HTTP request ready to send data. + var createCORSRequest = function(method, url) { + var xhr; + try { + xhr = new XMLHttpRequest(); + } catch(err){ + xhr = null; + } + if (xhr !== null && "withCredentials" in xhr) { + + // Check if the XMLHttpRequest object has a "withCredentials" + // property. + // "withCredentials" only exists on XMLHTTPRequest2 objects. + xhr.open(method, url, true); + } else if (typeof XDomainRequest != "undefined") { + + // Otherwise, check if XDomainRequest. + // XDomainRequest only exists in IE, and is IE's way of making CORS + // requests. + xhr = new XDomainRequest(); + xhr.open(method, url); + } else { + + // Otherwise, CORS is not supported by the browser. + xhr = null; + } + return xhr; + }; +{{/_supportsFetch}} +{{/_updateEnabled}} + + // Check if the JSON object still has any JavaScript snippets to run. + var hasJSFunctions = function() { + for (var i = i; i < json.javascriptProperties; i++) { + var body = getFromJson(json.javascriptProperties[i]); + if (body !== undefined && body.length > 0) { + return true; + } + } + return false; + } + + // Process the JavaScript properties. + var process = function(resolve, reject){ + processJsProperties(resolve, reject, json.javascriptProperties, false); + } + + var fireChangeFuncs = function(json) { + for (var i = 0; i < changeFuncs.length; i++) { + if (typeof changeFuncs[i] === 'function' && + changeFuncs[i].length === 1) { + changeFuncs[i](json); + } + } + } + +{{#_updateEnabled}} + // Process the response as json and call the resolve method. + var loadJSON = function(resolve, reject, responseText) { + json = JSON.parse(responseText); + + if (hasJSFunctions()) { + // json updated so fire 'on change' functions + // before executing any new JS properties that + // have come back. + fireChangeFuncs(json); + process(resolve, reject); + } else { + completed = true; + // json updated so fire 'on change' functions + // This must happen after completed = true in order + // for 'complete' functions to fire. + fireChangeFuncs(json); + resolve(json); +{{^_enableCookies}} + var fodCookies = getFodCookies(); + for (var key in fodCookies) { + if (fodCookies.hasOwnProperty(key)) { + deleteCookie(key); + } + } +{{/_enableCookies}} + } + } + + // Sends a POST request to the call-back URL to retrieve and updated + // JSON payload from the cloud service. A POST request is used so that + // parameters can be passed in the request body, this is to get around + // the limitations on the length of query strings. As POST requests are not + // cached by browsers, the result of the POST request is stored in session + // storage on a successful response. This can then be checked before making + // repeat requests to the call-back URL. + // Any cookie parameters that have been set by the executed JavaScript + // properties are added to the list of parameters, this list is then + // serialized as Form Data and sent in the POST body to the call-back URL, + // refreshing the JSON data. The new JSON is then loaded if the request is + // returned with a success status code. If there was a problem then the + // session storage items are invalidated and reject is called. + var processRequest = function(resolve, reject){ + loadParameters(); + + // Get additional parameters from cookies in case they are not sent + // by the browser. + var cookieParams = getParametersFromCookies(); + for(var cookie in cookieParams) { + var parts = cookieParams[cookie].split('='); + parameters[parts[0]] = parts[1]; + } + + saveParameters(); + + var params = []; + for (var param in parameters) { + if (parameters.hasOwnProperty(param)) { + params.push(param+"="+parameters[param]) + } + } + + // Add the session and sequence to the request + if (sessionId) { + params.push("session-id=" + sessionId); + } + if (sequence) { + params.push("sequence=" + sequence); + } + + var postBody = ""; + if (params.length > 0) { + postBody = params.join('&').replace(/%20/g, '+'); + } + +{{#_supportsFetch}} + fetch('{{{_url}}}', { + method: 'POST', + mode: 'cors', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: postBody + }) + .then(response => { + return response.text(); + }) + .then(responseText => { +{{/_supportsFetch}} +{{^_supportsFetch}} + // Request callback URL with additional parameters. + var xhr = createCORSRequest('POST', '{{{_url}}}'); + + // If there is no support for HTTP requests then call reject and throw + // a no CORS support error. + if (!xhr) { + reject(new Error('CORS not supported')); + return; + } + + // Add the HTTP header for POST form data. + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + + xhr.onload = function () { + + // Get the response body from the request. + var responseText = xhr.responseText; + +{{/_supportsFetch}} + // Cache the response text. + if (sessionStorage) { + sessionStorage.setItem(sessionKey, responseText); + } + // Load the JSON object from the response text + loadJSON(resolve, reject, responseText); + // Increment the sequence on a successful request + sequence++; +{{#_supportsFetch}} + }) + .catch(error => { + // Invalidate the cache on error. + clearCache(); + reject(error); + }); +{{/_supportsFetch}} +{{^_supportsFetch}} + }; + + xhr.onerror = function () { + // An error occurred with the request. Invalidate the cache and + // return the details in the call to reject method. + clearCache(); + reject(Error(xhr.statusText)); + }; + + // Send the GET request. + xhr.send(postBody); +{{/_supportsFetch}} + } +{{/_updateEnabled}} + + // Function logs errors, used to 'reject' a promise or for error callbacks. + var catchError = function(value) { + console.log(value.message || value); + } + + // Populate this instance of the FOD object with getters to access the + // properties. If the value is null then get the noValueMessage from the + // JSON object corresponding to the property. + var update = function(data){ + var self = this; + Object.getOwnPropertyNames(data).forEach(function(key) { + self[key] = {}; + for(var i in data[key]){ + var obj = self[key]; + (function(i) { + Object.defineProperty(obj, i, { + get: function (){ + if(data[key][i] === null && (i !== "javascriptProperties")){ + return data[key][i + "nullreason"]; + } else { + return data[key][i]; + } + } + }) + })(i); + } + }); + } + +{{#_hasDelayedProperties}} + // Get the JS property(s) that, when evaluated, will populate + // evidence that can be used to determine the value of the + // supplied property. + // The supplied name can either be a complete property name or a top level + // aspect name. + // Where the aspect name is given, ALL evidence properties under that + // key will be returned. + // Example property names are 'location.country' or 'devices.profiles.hardwarename' + // Example aspect names are 'location' or 'devices' + var getEvidenceProperties = function (name) { + var evidenceProperties = getFromJson(name + 'evidenceproperties'); + if(typeof evidenceProperties === "undefined") { + var item = getFromJson(name, true); + evidenceProperties = getEvidencePropertiesFromObject(item); + } + return evidenceProperties; + } + + // Get all values in any 'evidenceproperty' fields on this object + // or sub-objects. + var getEvidencePropertiesFromObject = function (dataObject) { + evidenceProperties = []; + + for (var prop in dataObject) { + if (dataObject.hasOwnProperty(prop)) { + var value = dataObject[prop]; + // Property name ends with 'evidenceproperties' so is + // what we're looking for. + // Add the values to the array if we don't already have it. + if (value !== null && Array.isArray(value) && endsWith(prop, 'evidenceproperties')) { + value.forEach(function(item, index) { + if(evidenceProperties.indexOf(item) === -1) { + evidenceProperties.push(item); + } + }); + } + // Item is an object so recursively call this method + // and add any resulting evidence properties to the list. + else if(typeof value === 'object' && value !== null) { + getEvidencePropertiesFromObject(value).forEach(function(item, index) { + if(evidenceProperties.indexOf(item) === -1) { + evidenceProperties.push(item); + } + }); + } + } + } + + return evidenceProperties; + } +{{/_hasDelayedProperties}} + +{{#_supportsPromises}} + this.promise = new Promise(function(resolve, reject) { + process(resolve,reject); + }); +{{/_supportsPromises}} + + this.onChange = function(resolve) { + changeFuncs.push(resolve); + } + + this.complete = function(resolve, properties) { +{{#_hasDelayedProperties}} + // If properties is set then check if we need to kick off + // processing of anything. + if(typeof properties !== "undefined") { + // If properties is a string then split on comma to produce + // an array of one or more key names. + if(typeof properties === "string") { + properties = properties.split(','); + } + if(Array.isArray(properties)) { + properties.forEach(function(key, i) { + // We pass an empty function rather than 'resolve' because we + // don't want to call resolve when a single evidence function + // evaluates but after all of them have completed. + // This is handled by the 'if(complete)' code below. + processJsProperties(function(json) {}, catchError, getEvidenceProperties(key), true); + }); + } + } + +{{/_hasDelayedProperties}} + if(completed){ + resolve(json); + }else{ + this.onChange(function(data) { + if(completed){ + resolve(data); + } + }) + } + }; + + // Update this instance with the initial JSON payload. + update.call(this, json); +{{#_supportsPromises}} + var parent = this; + this.promise.then(function(value) { + // JSON has been updated so replace the current instance. + update.call(parent, value); + completed = true; + }).catch(catchError); +{{/_supportsPromises}} +{{^_supportsPromises}} + process(function(json) {}, catchError); +{{/_supportsPromises}} +} + +var {{_objName}} = new fiftyoneDegreesManager(); \ No newline at end of file diff --git a/javascript-templates/README.md b/javascript-templates/README.md new file mode 100644 index 0000000..71f735a --- /dev/null +++ b/javascript-templates/README.md @@ -0,0 +1,8 @@ +## Purpose + +Store shared (mustache) templates to be used by the implementation of `JavaScriptBuilderElement` and other components across the languages. + +## Background + +The [language-independent specification](https://github.com/51Degrees/specifications) describes how JavaScriptBuilderElement is used [here](https://github.com/51Degrees/specifications/blob/36ff732360acb49221dc81237281264dac4eb897/pipeline-specification/pipeline-elements/javascript-builder.md). The mechanics is: javascript file is requested from the server (on-premise web integration) and is created from this mustache template by the JavaScriptBuilderElement. It then collects more evidence, sends it to the server and upon response calls a callback function providing the client with more precise device data. + diff --git a/readme.md b/readme.md index 6a0fc8a..c754603 100644 --- a/readme.md +++ b/readme.md @@ -49,3 +49,6 @@ To run the tests in this repository, make sure PHPUnit is installed then, in the ``` phpunit --fail-on-warning --display-warnings --log-junit test-results.xml ``` + +## Mustache template +`javascript-templates` is not a full-fledged PHP package installable via `composer`, however, it is shipped as a regularly updated, static dependency of this package. diff --git a/src/JavascriptBuilderElement.php b/src/JavascriptBuilderElement.php index 0bf6452..fb9f840 100644 --- a/src/JavascriptBuilderElement.php +++ b/src/JavascriptBuilderElement.php @@ -177,7 +177,10 @@ public function processInternal($flowData) $vars["_parameters"] = json_encode($jsParams); - $output = $m->render(file_get_contents($this->getTemplatePath()), $vars); + $output = $m->render( + file_get_contents(__DIR__ . '/../javascript-templates/JavaScriptResource.mustache'), + $vars + ); if($this->minify) { // Minify the output @@ -190,19 +193,4 @@ public function processInternal($flowData) return; } - - private function getTemplatePath() - { - $templatePath = '51degrees/fiftyone.pipeline.javascript-templates/JavaScriptResource.mustache'; - $prefixes = ['/../../../', '/../vendor/']; - - foreach ($prefixes as $prefix) { - $path = __DIR__ . $prefix . $templatePath; - if (file_exists($path)) { - return $path; - } - } - - throw new \Exception('Could not find JavaScriptResource template'); - } }