UNPKG

@qooxdoo/framework

Version:

The JS Framework for Coders

1,082 lines (940 loc) 33.5 kB
/* ************************************************************************ qooxdoo - the new era of web development http://qooxdoo.org Copyright: 2006 STZ-IDA, Germany, http://www.stz-ida.de 2006 Derrell Lipman License: MIT: https://opensource.org/licenses/MIT See the LICENSE file in the project's top-level directory for details. Authors: * Andreas Junghans (lucidcake) * Derrell Lipman (derrell) ************************************************************************ */ /** * Provides a Remote Procedure Call (RPC) implementation. * * Each instance of this class represents a "Service". These services can * correspond to various concepts on the server side (depending on the * programming language/environment being used), but usually, a service means * a class on the server. * * In case multiple instances of the same service are needed, they can be * distinguished by ids. If such an id is specified, the server routes all * calls to a service that have the same id to the same server-side instance. * * When calling a server-side method, the parameters and return values are * converted automatically. Supported types are int (and Integer), double * (and Double), String, Date, Map, and JavaBeans. Beans must have a default * constructor on the server side and are represented by simple JavaScript * objects on the client side (used as associative arrays with keys matching * the server-side properties). Beans can also be nested, but be careful not to * create circular references! There are no checks to detect these (which would * be expensive), so you as the user are responsible for avoiding them. * * A simple example: * <pre class='javascript'> * function callRpcServer () * { * var rpc = new qx.io.remote.Rpc(); * rpc.setTimeout(10000); * rpc.setUrl("http://127.0.0.1:8007"); * rpc.setServiceName("qooxdoo.admin"); * * // call a remote procedure -- takes no arguments, returns a string * var that = this; * this.RpcRunning = rpc.callAsync( * function(result, ex, id) * { * that.RpcRunning = null; * if (ex == null) { * alert(result); * } else { * alert("Async(" + id + ") exception: " + ex); * } * }, * "fss.getBaseDir"); * } * </pre> * __fss.getBaseDir__ is the remote procedure in this case, potential arguments * would be listed after the procedure name. * <p> * Passing data from the client (qooxdoo) side is demonstrated in the * qooxdoo-contrib project RpcExample. There are three ways to issue a remote * procedure call: synchronously (qx.io.remote.Rpc.callSync -- dangerous * because it blocks the whole browser, not just your application, so is * highly discouraged); async with results via a callback function * (qx.io.remote.Rpc.callAsync) and async with results via an event listener * (qx.io.remote.Rpc.callAsyncListeners). * <p> * You may also find the server writer's guide helpful: * http://manual.qooxdoo.org/${qxversion}/pages/communication/rpc_server_writer_guide.html * * @ignore(qx.core.ServerSettings.*) */ qx.Class.define("qx.io.remote.Rpc", { extend : qx.core.Object, /* ***************************************************************************** CONSTRUCTOR ***************************************************************************** */ /** * @param url {String} identifies the url where the service * is found. Note that if the url is to * a domain (server) other than where the * qooxdoo script came from, i.e. it is * cross-domain, then you must also call * the setCrossDomain(true) method to * enable the ScriptTransport instead of * the XmlHttpTransport, since the latter * can not handle cross-domain requests. * * @param serviceName {String} identifies the service. For the Java * implementation, this is the fully * qualified name of the class that offers * the service methods * (e.g. "my.pkg.MyService"). */ construct : function(url, serviceName) { this.base(arguments); if (url !== undefined) { this.setUrl(url); } if (serviceName != null) { this.setServiceName(serviceName); } if (qx.core.ServerSettings) { this.__currentServerSuffix = qx.core.ServerSettings.serverPathSuffix; } }, /* ***************************************************************************** EVENTS ***************************************************************************** */ events : { /** * Fired when call is completed. */ "completed" : "qx.event.type.Event", /** * Fired when call aborted. */ "aborted" : "qx.event.type.Event", /** * Fired when call failed. */ "failed" : "qx.event.type.Event", /** * Fired when call timed out. */ "timeout" : "qx.event.type.Event" }, /* ***************************************************************************** STATICS ***************************************************************************** */ statics : { /** * Origins of errors */ origin : { server : 1, application : 2, transport : 3, local : 4 }, /** * Locally-detected errors */ localError : { timeout : 1, abort : 2, nodata : 3 }, /** * Boolean flag which controls the stringification of date objects. * <code>null</code> for the default behavior, acts like false * <code>true</code> for stringifying dates the old, qooxdoo specific way * <code>false</code> using the native toJSON of date objects. * * When enabled, dates are converted to and parsed from * a literal that complies to the format * * <code>new Date(Date.UTC(year,month,day,hour,min,sec,ms))</code> * * The server can fairly easily parse this in its JSON * implementation by stripping off "new Date(Date.UTC(" * from the beginning of the string, and "))" from the * end of the string. What remains is the set of * comma-separated date components, which are also very * easy to parse. * * The work-around compensates for the fact that while the * Date object is a primitive type in Javascript, the * specification neglects to provide a literal form for it. */ CONVERT_DATES : null, /** * Boolean flag which controls whether to expect and verify a JSON * response. * * Should be <code>true</code> when backend returns valid JSON. * * Date literals are parsed when CONVERT_DATES is <code>true</code> * and comply to the format * * <code>"new Date(Date.UTC(year,month,day,hour,min,sec,ms))"</code> * * Note the surrounding quotes that encode the literal as string. * * Using valid JSON is recommended, because it allows to use * {@link qx.lang.Json#parse} for parsing. {@link qx.lang.Json#parse} * is preferred over the potentially insecure <code>eval</code>. */ RESPONSE_JSON : null, /** * Creates an URL for talking to a local service. A local service is one that * lives in the same application as the page calling the service. For backends * that don't support this auto-generation, this method returns null. * * @param instanceId {String ? null} an optional identifier for the * server side instance that should be * used. All calls to the same service * with the same instance id are * routed to the same object instance * on the server. The instance id can * also be used to provide additional * data for the service instantiation * on the server. * @return {String} the url. */ makeServerURL : function(instanceId) { var retVal = null; if (qx.core.ServerSettings) { retVal = qx.core.ServerSettings.serverPathPrefix + "/.qxrpc" + qx.core.ServerSettings.serverPathSuffix; if (instanceId != null) { retVal += "?instanceId=" + instanceId; } } return retVal; } }, /* ***************************************************************************** PROPERTIES ***************************************************************************** */ properties : { /* --------------------------------------------------------------------------- PROPERTIES --------------------------------------------------------------------------- */ /** The timeout for asynchronous calls in milliseconds. */ timeout : { check : "Integer", nullable : true }, /** * Indicate that the request is cross domain. * * A request is cross domain if the request's URL points to a host other * than the local host. This switches the concrete implementation that is * used for sending the request from qx.io.remote.transport.XmlHttp to * qx.io.remote.transport.Script because only the latter can handle cross * domain requests. */ crossDomain : { check : "Boolean", init : false }, /** The URL at which the service is located. */ url : { check : "String", nullable : true }, /** The service name. */ serviceName : { check : "String", nullable : true }, /** * Data sent as "out of band" data in the request to the server. The * format of the data is opaque to RPC and may be recognized only by * particular servers It is up to the server to decide what to do with * it: whether to ignore it, handle it locally before calling the * specified method, or pass it on to the method. This server data is * not sent to the server if it has been set to 'null'. */ serverData : { check : "Object", nullable : true }, /** * Username to use for HTTP authentication. Null if HTTP authentication * is not used. */ username : { check : "String", nullable : true }, /** * Password to use for HTTP authentication. Null if HTTP authentication * is not used. */ password : { check : "String", nullable : true }, /** Use Basic HTTP Authentication */ useBasicHttpAuth : { check : "Boolean", nullable : true }, /** * EXPERIMENTAL * * Whether to use the original qooxdoo RPC protocol or the * now-standardized Version 2 protocol. Defaults to the original qooxdoo * protocol for backward compatibility. * * Valid values are "qx1" and "2.0". */ protocol : { init : "qx1", check : function(val) { return val == "qx1" || val == "2.0"; } } }, /* ***************************************************************************** MEMBERS ***************************************************************************** */ members : { __previousServerSuffix : null, __currentServerSuffix : null, /** * Factory method to create a request object. By default, a POST request * will be made, and the expected response type will be * "application/json". Classes extending this one may override this method * to obtain a Request object with different parameters. * * @return {qx.io.remote.Request} */ createRequest: function() { return new qx.io.remote.Request(this.getUrl(), "POST", "application/json"); }, /** * Factory method to create the object containing the remote procedure * call data. By default, a qooxdoo-style RPC request is built, which * contains the following members: "service", "method", "id", and * "params". If a different style of RPC request is desired, a class * extending this one may override this method. * * @param id {Integer} * The unique sequence number of this request. * * @param method {String} * The name of the method to be called * * @param parameters {Array} * An array containing the arguments to the called method. * * @param serverData {var} * "Out-of-band" data to be provided to the server. * * @return {Object} * The object to be converted to JSON and passed to the JSON-RPC * server. */ createRpcData: function(id, method, parameters, serverData) { var requestObject; var service; // Create a protocol-dependent request object if (this.getProtocol() == "qx1") { // Create a qooxdoo-modified version 1.0 rpc data object requestObject = { "service" : method == "refreshSession" ? null : this.getServiceName(), "method" : method, "id" : id, "params" : parameters }; // Only add the server_data member if there is actually server data if (serverData) { requestObject.server_data = serverData; } } else { // If there's a service name, we'll prepend it to the method name service = this.getServiceName(); if (service && service != "") { service += "."; } else { service = ""; } // Create a standard version 2.0 rpc data object requestObject = { "jsonrpc" : "2.0", "method" : service + method, "id" : id, "params" : parameters }; } return requestObject; }, /** * Internal RPC call method * * @lint ignoreDeprecated(eval) * * @param args {Array} * array of arguments * * @param callType {Integer} * 0 = sync, * 1 = async with handler, * 2 = async event listeners * * @param refreshSession {Boolean} * whether a new session should be requested * * @return {var} the method call reference. * @throws {Error} An error. */ _callInternal : function(args, callType, refreshSession) { var self = this; var offset = (callType == 0 ? 0 : 1); var whichMethod = (refreshSession ? "refreshSession" : args[offset]); var handler = args[0]; var argsArray = []; var eventTarget = this; var protocol = this.getProtocol(); for (var i=offset+1; i<args.length; ++i) { argsArray.push(args[i]); } var req = this.createRequest(); // Get any additional out-of-band data to be sent to the server var serverData = this.getServerData(); // Create the request object var rpcData = this.createRpcData(req.getSequenceNumber(), whichMethod, argsArray, serverData); req.setCrossDomain(this.getCrossDomain()); if (this.getUsername()) { req.setUseBasicHttpAuth(this.getUseBasicHttpAuth()); req.setUsername(this.getUsername()); req.setPassword(this.getPassword()); } req.setTimeout(this.getTimeout()); var ex = null; var id = null; var result = null; var response = null; var handleRequestFinished = function(eventType, eventTarget) { switch(callType) { case 0: // sync break; case 1: // async with handler function try { handler(result, ex, id); } catch(e) { eventTarget.error( "rpc handler threw an error:" + " id=" + id + " result=" + qx.lang.Json.stringify(result) + " ex=" + qx.lang.Json.stringify(ex), e); } break; case 2: // async with event listeners // Dispatch the event to our listeners. if (!ex) { eventTarget.fireDataEvent(eventType, response); } else { // Add the id to the exception ex.id = id; if (args[0]) // coalesce { // They requested that we coalesce all failure types to // "failed" eventTarget.fireDataEvent("failed", ex); } else { // No coalese so use original event type eventTarget.fireDataEvent(eventType, ex); } } } }; var addToStringToObject = function(obj) { if (protocol == "qx1") { obj.toString = function() { switch(obj.origin) { case qx.io.remote.Rpc.origin.server: return "Server error " + obj.code + ": " + obj.message; case qx.io.remote.Rpc.origin.application: return "Application error " + obj.code + ": " + obj.message; case qx.io.remote.Rpc.origin.transport: return "Transport error " + obj.code + ": " + obj.message; case qx.io.remote.Rpc.origin.local: return "Local error " + obj.code + ": " + obj.message; default: return ("UNEXPECTED origin " + obj.origin + " error " + obj.code + ": " + obj.message); } }; } else // protocol == "2.0" { obj.toString = function() { var ret; ret = "Error " + obj.code + ": " + obj.message; if (obj.data) { ret += " (" + obj.data + ")"; } return ret; }; } }; var makeException = function(origin, code, message) { var ex = new Object(); if (protocol == "qx1") { ex.origin = origin; } ex.code = code; ex.message = message; addToStringToObject(ex); return ex; }; req.addListener("failed", function(evt) { var code = evt.getStatusCode(); ex = makeException(qx.io.remote.Rpc.origin.transport, code, qx.io.remote.Exchange.statusCodeToString(code)); id = this.getSequenceNumber(); handleRequestFinished("failed", eventTarget); }); req.addListener("timeout", function(evt) { this.debug("TIMEOUT OCCURRED"); ex = makeException(qx.io.remote.Rpc.origin.local, qx.io.remote.Rpc.localError.timeout, "Local time-out expired for "+ whichMethod); id = this.getSequenceNumber(); handleRequestFinished("timeout", eventTarget); }); req.addListener("aborted", function(evt) { ex = makeException(qx.io.remote.Rpc.origin.local, qx.io.remote.Rpc.localError.abort, "Aborted " + whichMethod); id = this.getSequenceNumber(); handleRequestFinished("aborted", eventTarget); }); req.addListener("completed", function(evt) { response = evt.getContent(); // server may have reset, giving us no data on our requests if (response === null) { ex = makeException(qx.io.remote.Rpc.origin.local, qx.io.remote.Rpc.localError.nodata, "No data in response to " + whichMethod); id = this.getSequenceNumber(); handleRequestFinished("failed", eventTarget); return; } // Parse. Skip when response is already an object // because the script transport was used. if (!qx.lang.Type.isObject(response)) { // Handle converted dates if (self._isConvertDates()) { // Parse as JSON and revive date literals if (self._isResponseJson()) { response = qx.lang.Json.parse(response, function(key, value) { if (value && typeof value === "string") { if (value.indexOf("new Date(Date.UTC(") >= 0) { var m = value.match(/new Date\(Date.UTC\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)\)/); return new Date(Date.UTC(m[1],m[2],m[3],m[4],m[5],m[6],m[7])); } } return value; }); // Eval } else { response = response && response.length > 0 ? eval('(' + response + ')') : null; } // No special date handling required, JSON assumed } else { response = qx.lang.Json.parse(response); } } id = response["id"]; if (id != this.getSequenceNumber()) { this.warn("Received id (" + id + ") does not match requested id " + "(" + this.getSequenceNumber() + ")!"); } // Determine if an error was returned. Assume no error, initially. var eventType = "completed"; var exTest = response["error"]; if (exTest != null) { // There was an error result = null; addToStringToObject(exTest); ex = exTest; // Change the event type eventType = "failed"; } else { result = response["result"]; if (refreshSession) { result = eval("(" + result + ")"); var newSuffix = qx.core.ServerSettings.serverPathSuffix; if (self.__currentServerSuffix != newSuffix) { self.__previousServerSuffix = self.__currentServerSuffix; self.__currentServerSuffix = newSuffix; } self.setUrl(self.fixUrl(self.getUrl())); } } handleRequestFinished(eventType, eventTarget); }); // Provide a replacer when convert dates is enabled var replacer = null; if (this._isConvertDates()) { replacer = function(key, value) { // The value passed in is of type string, because the Date's // toJson gets applied before. Get value from containing object. value = this[key]; if (qx.lang.Type.isDate(value)) { var dateParams = value.getUTCFullYear() + "," + value.getUTCMonth() + "," + value.getUTCDate() + "," + value.getUTCHours() + "," + value.getUTCMinutes() + "," + value.getUTCSeconds() + "," + value.getUTCMilliseconds(); return "new Date(Date.UTC(" + dateParams + "))"; } return value; }; } req.setData(qx.lang.Json.stringify(rpcData, replacer)); req.setAsynchronous(callType > 0); if (req.getCrossDomain()) { // Our choice here has no effect anyway. This is purely informational. req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); } else { // When not cross-domain, set type to text/json req.setRequestHeader("Content-Type", "application/json"); } // Do not parse as JSON. Later done conditionally. req.setParseJson(false); req.send(); if (callType == 0) { if (ex != null) { var error = new Error(ex.toString()); error.rpcdetails = ex; throw error; } return result; } else { return req; } }, /** * Helper method to rewrite a URL with a stale session id (so that it includes * the correct session id afterwards). * * @param url {String} the URL to examine. * @return {String} the (possibly re-written) URL. */ fixUrl : function(url) { if (this.__previousServerSuffix == null || this.__currentServerSuffix == null || this.__previousServerSuffix == "" || this.__previousServerSuffix == this.__currentServerSuffix) { return url; } var index = url.indexOf(this.__previousServerSuffix); if (index == -1) { return url; } return (url.substring(0, index) + this.__currentServerSuffix + url.substring(index + this.__previousServerSuffix.length)); }, /** * Makes a synchronous server call. The method arguments (if any) follow * after the method name (as normal JavaScript arguments, separated by * commas, not as an array). * * If a problem occurs when making the call, an exception is thrown. * * * WARNING. With some browsers, the synchronous interface * causes the browser to hang while awaiting a response! If the server * decides to pause for a minute or two, your browser may do nothing * (including refreshing following window changes) until the response is * received. Instead, use the asynchronous interface. * * * YOU HAVE BEEN WARNED. * * * @param methodName {String} the name of the method to call. * @param args {Array} an array of values passed through to the backend. * @return {var} the result returned by the server. */ callSync : function(methodName,args) { return this._callInternal(arguments, 0); }, /** * Makes an asynchronous server call. The method arguments (if any) follow * after the method name (as normal JavaScript arguments, separated by * commas, not as an array). * * When an answer from the server arrives, the <code>handler</code> * function is called with the result of the call as the first, an * exception as the second parameter, and the id (aka sequence number) of * the invoking request as the third parameter. If the call was * successful, the second parameter is <code>null</code>. If there was a * problem, the second parameter contains an exception, and the first one * is <code>null</code>. * * * The return value of this method is a call reference that you can store * if you want to abort the request later on. This value should be treated * as opaque and can change completely in the future! The only thing you * can rely on is that the <code>abort</code> method will accept this * reference and that you can retrieve the sequence number of the request * by invoking the getSequenceNumber() method (see below). * * * If a specific method is being called, asynchronously, a number of times * in succession, the getSequenceNumber() method may be used to * disambiguate which request a response corresponds to. The sequence * number value is a value which increments with each request.) * * * @param handler {Function} the callback function. * @param methodName {String} the name of the method to call. * @param args {Array} an array of values passed through to the backend. * @return {var} the method call reference. */ callAsync : function(handler, methodName, args) { return this._callInternal(arguments, 1); }, /** * Makes an asynchronous server call and dispatches an event upon completion * or failure. The method arguments (if any) follow after the method name * (as normal JavaScript arguments, separated by commas, not as an array). * * When an answer from the server arrives (or fails to arrive on time), if * an exception occurred, a "failed", "timeout" or "aborted" event, as * appropriate, is dispatched to any waiting event listeners. If no * exception occurred, a "completed" event is dispatched. * * * When a "failed", "timeout" or "aborted" event is dispatched, the event * data contains an object with the properties 'origin', 'code', 'message' * and 'id'. The object has a toString() function which may be called to * convert the exception to a string. * * * When a "completed" event is dispatched, the event data contains a * map with the JSON-RPC sequence number and result: * <p> * { * id: rpc_id, * result: json-rpc result * } * * * The return value of this method is a call reference that you can store * if you want to abort the request later on. This value should be treated * as opaque and can change completely in the future! The only thing you * can rely on is that the <code>abort</code> method will accept this * reference and that you can retrieve the sequence number of the request * by invoking the getSequenceNumber() method (see below). * * * If a specific method is being called, asynchronously, a number of times * in succession, the getSequenceNumber() method may be used to * disambiguate which request a response corresponds to. The sequence * number value is a value which increments with each request.) * * * @param coalesce {Boolean} coalesce all failure types ("failed", * "timeout", and "aborted") to "failed". * This is reasonable in many cases, as * the provided exception contains adequate * disambiguating information. * @param methodName {String} the name of the method to call. * @param args {Array} an array of values passed through to the backend. * @return {var} the method call reference. */ callAsyncListeners : function(coalesce, methodName, args) { return this._callInternal(arguments, 2); }, /** * Refreshes a server session by retrieving the session id again from the * server. * * The specified handler function is called when the refresh is * complete. The first parameter can be <code>true</code> (indicating that * a refresh either wasn't necessary at this time or it was successful) or * <code>false</code> (indicating that a refresh would have been necessary * but can't be performed because the server backend doesn't support * it). If there is a non-null second parameter, it's an exception * indicating that there was an error when refreshing the session. * * * @param handler {Function} a callback function that is called when the * refresh is complete (or failed). */ refreshSession : function(handler) { if (qx.core.ServerSettings && qx.core.ServerSettings.serverPathSuffix) { var timeDiff = (new Date()).getTime() - qx.core.ServerSettings.lastSessionRefresh; if (timeDiff / 1000 > (qx.core.ServerSettings.sessionTimeoutInSeconds - 30)) { // this.info("refreshing session"); this._callInternal([ handler ], 1, true); } else { handler(true); // session refresh was OK (in this case: not needed) } } else { handler(false); // no refresh possible, but would be necessary } }, /** * Whether to convert date objects to pseudo literals and * parse with eval. * * Controlled by {@link #CONVERT_DATES}. * * @return {Boolean} Whether to convert. */ _isConvertDates: function() { return !!(qx.io.remote.Rpc.CONVERT_DATES); }, /** * Whether to expect and verify a JSON response. * * Controlled by {@link #RESPONSE_JSON}. * * @return {Boolean} Whether to expect JSON. */ _isResponseJson: function() { return !!(qx.io.remote.Rpc.RESPONSE_JSON); }, /** * Aborts an asynchronous server call. Consequently, the callback function * provided to <code>callAsync</code> or <code>callAsyncListeners</code> * will be called with an exception. * * @param opaqueCallRef {var} the call reference as returned by * <code>callAsync</code> or * <code>callAsyncListeners</code> */ abort : function(opaqueCallRef) { opaqueCallRef.abort(); } } });