baja/ord/Ord.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/*global niagara:false*/

/**
 * Defines {@link baja.Ord}.
 * @module baja/ord/Ord
 */
define([
  "bajaPromises",
  "bajaScript/nav",
  "bajaScript/baja/obj/Simple",
  "bajaScript/baja/ord/OrdTarget",
  "bajaScript/baja/comm/Callback" ], function (
    Promise,
    baja,
    Simple,
    OrdTarget,
    Callback) {
  
  "use strict";
  
  var subclass = baja.subclass,
      callSuper = baja.callSuper,
      objectify = baja.objectify,
      strictArg = baja.strictArg,
      bajaDef = baja.def;

  var VARIABLE_REGEX = "\\$\\(([^)]*)\\)?";

  /**
   * NCCB-27229
   *
   * When resolving a relativized Ord the default base should be the current
   * session. Most of the time this is localhost, but in Workbench it could be
   * any session (e.g. platform:).
   *
   * getSessionOrd() is injected by BWebWidget.
   */
  function getDefaultBaseOrd() {
    var sessionOrd;
    if (baja.isOffline() &&
      typeof niagara !== 'undefined' &&
      niagara.env &&
      typeof niagara.env.getSessionOrd === 'function') {
      sessionOrd = niagara.env.getSessionOrd();
    }
    if (!sessionOrd) {
      sessionOrd = 'local:';
    }
    return baja.Ord.make(sessionOrd);
  }
  
  /**
   * Object Resolution Descriptor.
   * 
   * An ORD is how we can access Objects in the Server from BajaScript. It's 
   * similar to a URI but is much more powerful and extensible. For more
   * information, please see the Niagara developer documentation on ORDs and how
   * they're used.
   * 
   * If more than one ORD needs to be resolved then use a {@link baja.BatchResolve}.
   * 
   * This Constructor shouldn't be invoked directly. Please use the `make()` 
   * methods to create an instance of an ORD.
   *
   * @see baja.Ord.make
   * @see baja.BatchResolve
   *
   * @class
   * @alias baja.Ord
   * @extends baja.Simple
   * 
   * @example
   *   <caption>Resolve an ORD</caption>
   *   baja.Ord.make("station:|slot:/Folder/NumericWritable").get({ lease: true })
   *     .then(function (numericWritable) {
   *       baja.outln(numericWritable.getOutDisplay());
   *     });
   */
  var Ord = function Ord(ord) {
    callSuper(Ord, this, arguments);
    this.$ord = strictArg(ord, String);    
  };
  
  subclass(Ord, Simple);
  
  /**
   * Default ORD instance.
   * @type {baja.Ord}
   */
  Ord.DEFAULT = new Ord("null");
    
  /**
   * Make an ORD.
   * 
   * The argument can be a `String`, `Ord` or an `Object`.
   * 
   * If an `Object` is passed in then if there's a `base` and `child` property, 
   * this will be used to construct the ORD (by calling `toString` on each). 
   * Otherwise `toString` will be called on the `Object` for the ORD.
   *
   * @param {String|baja.Ord|Object} ord
   * @returns {baja.Ord}
   * 
   * @example
   *   <caption>Resolve an ORD</caption>
   *   baja.Ord.make("station:|slot:/Folder/NumericWritable").get({ lease: true })
   *     .then(function (numericWritable) {
   *       baja.outln(numericWritable.getOutDisplay());
   *     });
   */
  Ord.make = function (ord) {
    if (arguments.length === 0) {
      return Ord.DEFAULT;
    }
    
    var ordString;
    
    // Handle child and base
    if (typeof ord === "object" && ord.base && ord.child) {
      ordString = ord.base.toString() + "|" + ord.child.toString();
    } else {
      ordString = ord.toString(); 
    }  
        
    // Handle URL decoding
    if (ordString.match(/^\/ord/)) {
      // Remove '/ord?' or '/ord/'
      ordString = ordString.substring(5, ordString.length);
      
      // Replace this with the pipe character
      ordString = decodeURIComponent(ordString);
    }
    
    if (ordString === "" || ordString === "null") {
      return Ord.DEFAULT;
    }
    
    return new Ord(ordString);
  };
  
  /**
   * Make an ORD.
   *
   * @see baja.Ord.make
   *
   * @param {String|baja.Ord|Object} ord
   * @returns {baja.Ord}
   */
  Ord.prototype.make = function (ord) {
    return Ord.make(ord);
  };
  
  /**
   * Decode an ORD from a `String`.
   *
   * @param {String} str  the ORD String.
   * @returns {baja.Ord} the decoded ORD.
   */
  Ord.prototype.decodeFromString = function (str) {
    return Ord.make(str);
  };
  
  /**
   * Encode an ORD to a `String`.
   *
   * @returns {String} the ORD encoded to a String.
   */
  Ord.prototype.encodeToString = function () {
    return this.$ord;
  };

  /**
   * @returns {boolean} if this represents the null ORD.
   * @since Niagara 4.10
   */
  Ord.prototype.isNull = function () {
    return this === Ord.DEFAULT;
  };
      
  /**
   * Return an `String` representation of the object.
   *
   * @returns {String} a String representation of an ORD.
   */
  Ord.prototype.toString = function () {
    return this.$ord;
  };
  
  /**
   * Return the inner value of this `Object`.
   *
   * @returns {String} a String representation of an ORD.
   */
  Ord.prototype.valueOf = function () {
    return this.toString();
  };
      
  /**
   * Parse an ORD to a number of ORD Query objects.
   *
   * @returns {baja.OrdQueryList} a list of ORDs to resolve.
   */
  Ord.prototype.parse = function () {
    // TODO: Validate all characters are valid
    var os = this.$ord.split("|"), // ORDs
        list = new baja.OrdQueryList(),
        i,
        ind,
        schemeName,
        scheme,
        body;
    
    if (this.$ord === "null") {
      return list; 
    }

    for (i = 0; i < os.length; ++i) {      
      ind = os[i].indexOf(":");
      if (ind === -1) {
        throw new Error("Unable to parse ORD: " + os[i]);
      }
        
      schemeName = os[i].substring(0, ind);
      body = os[i].substring(ind + 1, os[i].length);      
      scheme = baja.OrdScheme.lookup(schemeName);
            
      // Create the ORD scheme      
      list.add(scheme.parse(schemeName, body));
    }
    return list;
  };
     
  /**
   * Resolve an ORD.
   * 
   * Resolving an ORD consists of parsing and processing it to get a result. 
   * The result is an ORD Target.
   * 
   * Any network calls that result from processing an ORD are always 
   * asynchronous.
   * 
   * The `resolve` method requires an `ok` function callback or an object 
   * literal that contains the method's arguments.
   * 
   * Please note that unlike other methods that require network calls, no 
   * batch object can be specified!   
   *
   * @see module:baja/ord/OrdTarget
   * @see baja.Ord#get
   * @see baja.RelTime
   *
   * @param {Object} [obj] the object literal that contains the method's 
   * arguments.
   * @param {Function} [obj.ok] (Deprecated: use Promise) the ok function called
   * once the ORD has been successfully resolved. The ORD Target is passed to
   * this function when invoked.
   * @param {Function} [obj.fail] (Deprecated: use Promise) the fail function
   * called if the ORD fails to resolve. An error cause is passed to this
   * function when invoked.
   * @param [obj.base] the base Object to resolve the ORD against.
   * @param {Boolean} [obj.lease] if defined and true, any Components are 
   * temporarily subscribed.
   * @param {Number|baja.RelTime} [obj.leaseTime] the amount of time in 
   * milliseconds to lease for (`lease` argument must be true). As well as a 
   * Number, this can also be a {@link baja.RelTime}. If undefined, BajaScript's
   * default lease time will be used.
   * @param {baja.Subscriber} [obj.subscriber] if defined the `Component` is 
   * subscribed using this `Subscriber`.
   * @param {Object} [obj.cursor] if defined, this specifies parameters for 
   * iterating through a Cursor (providing the ORD resolves to a Collection 
   * or Table). For more information, please see 
   * {@link baja.coll.tableMixIn.cursor}.
   * @returns {Promise.<Object>} a promise that will be resolved with an
   * OrdTarget when the ORD has been resolved.
   * 
   * @example
   *   <caption>Resolve an ORD</caption>
   *   baja.Ord.make("station:|slot:/").resolve({
   *     lease: true // ensure any resolved Components are leased
   *   })
   *     .then(function (target) {
   *       // process the ORD Target
   *     })
   *     .catch(function (err) {
   *       // ORD failed to resolve
   *     });
   */
  Ord.prototype.resolve = function (obj) {
    var inpObj = obj;
    obj = objectify(obj, 'ok');

    var inpBase = obj.base,
        baseIsNavNode = baja.hasType(inpBase, 'baja:INavNode'),
        baseOrd = baseIsNavNode ? inpBase.getNavOrd() : getDefaultBaseOrd(),
        base = bajaDef(inpBase, baja.nav.localhost),
        cb = obj.cb === undefined ? new Callback(obj.ok, obj.fail) : obj.cb,
        subscriber = bajaDef(obj.subscriber, null),
        lease = bajaDef(obj.lease, false), 
        leaseTime = obj.leaseTime, // If undefined, lease will use lease default
        full = bajaDef(obj.full, false),
        cursor = obj.cursor,
        ordQueries,
        target,
        options,
        newTarget;

    if (inpBase && !baseIsNavNode) {
      cb.fail(new Error('Base must be a NavNode'));
      return cb.promise();
    }

    if (typeof inpObj === "function" || arguments.length === 0) {
      obj.lease = true;
    }   

    // Ensure 'this' in callback is the target's Component. If it's not a Component then 
    // fallback to the resolved Object.
    cb.addOk(function (ok, fail, target) {
      var resolvedObj = target.getComponent();
      if (!resolvedObj) {
        resolvedObj = target.getObject();
      }
      if (resolvedObj !== null) {
        ok.call(resolvedObj, target);
      } else {
        ok(target);
      }
    });
     
    if (subscriber !== null) {
       // If we need to subscribe using a Subscriber once the Component is resolved...
      cb.addOk(function (ok, fail, target) {
        function newOk() {
          ok(target);
        }
        var comp = target.getComponent();
        if (comp !== null && comp.isMounted()) {
          subscriber.subscribe({
            "comps": [ comp ],
            "ok": newOk, 
            "fail": fail
          });
        } else {
          newOk();
        }
      });
    }     
    
    if (lease) {
      // If we need to lease once the Component is resolved...
      cb.addOk(function (ok, fail, target) {
        function newOk() {
          ok(target);
        }
        var comp = target.getComponent();
        if (comp !== null && comp.isMounted()) {
          comp.lease({
            "ok": newOk, 
            "fail": fail,
            "time": leaseTime,
            "importAsync": true
          });
        } else {
          newOk();
        }
      });
    }
    
    try {            
      // Check the user isn't trying to batch an ORD as this isn't supported
      if (obj.batch) {
        return failCallback(cb, "Cannot batch ORD resolution");
      }
    
      ordQueries = this.parse(); 
      if (ordQueries.isEmpty()) {
        return failCallback(cb, "Cannot resolve null ORD: " + this.toString());
      }
      
      target = new OrdTarget();
      target.object = base;
      target.ord = this;
      
      options = {
        "full": full,
        "callback": cb,
        "queries": ordQueries,
        "ord": this,
        "cursor": cursor
      };
      
      // Normalize
      ordQueries.normalize();
            
      // If there are ORD Schemes that aren't implemented in BajaScript then we 
      // simply make a network call and resolve the ORD Server side
      if (!ordQueries.isClientResolvable() || obj.forceServerResolve) {
        newTarget = new OrdTarget(target);
        var updateTargetObject;
               
        cb.addOk(function (ok, fail, resp) {
          baja.bson.importUnknownTypes(resp, function () {

            if (resp.o) {
              // Decode the result
              var t = newTarget.object = baja.bson.decodeValue(resp.o, baja.$serverDecodeContext);
              updateTargetObject = true;

              // If we've got a collection result cached then call 'cursor'.
              if (resp.c &&
                baja.hasType(t) &&
                typeof t.cursor === "function") {
                cursor.$data = resp.c;
                t.cursor(cursor);
              }
            }

            // since Niagara 4.10
            // All requests to the OrdChannel will get a SlotPath and the space as 
            // response if the target is already mounted. Resolving the SlotPath
            // will get us the expected mounted component thus reducing any additional
            // network calls to get the target objects with all slots loaded.
            // If the resolved object and the component are not the same we ensure both 
            // getComponent and getObject resolve to the expected values.
            if (resp && resp.sp) {
              var resolveParams = {
                ok: function (target) {
                  if (updateTargetObject) {
                    var updatedTarget = new OrdTarget(target);
                    updatedTarget.object = newTarget.object;
                    ok(updatedTarget);
                  } else {
                    ok(target);
                  }
                },
                fail: fail,
                lease: bajaDef(obj.lease, false),
                leaseTime: obj && obj.leaseTime,
                subscriber: bajaDef(obj.subscriber, null),
                full: bajaDef(obj.full, false)
              };

              var ordStr = resp.cs + "|slot:" + resp.sp;
              if (resp.pp) {
                ordStr += '|slot:' + resp.pp.join('/');
              }
              Ord.make(ordStr).resolve(resolveParams);
            } else {
              // Finished iterating so just make the callback
              ok(newTarget);
            }
          }, fail);
        });
        
        // If Cursor information is defined, ensure we set some defaults
        if (options.cursor) {
          options.cursor.limit = options.cursor.limit || 10;
          options.cursor.offset = options.cursor.offset || 0;
        }
        
        // Make the network call to resolve the complete ORD Server side       
        baja.comm.resolve(this, baseOrd, cb, options);
      } else {
                                
        // Resolve the ORD. Each ORD scheme must call 'resolveNext' on the cursor to process the next 
        // part of the ORD. This design has been chosen because some ORD schemes may need to make network calls.
        // If the network call is asynchronous in nature then they'll be a delay before the ORD can process further    
        ordQueries.getCursor().resolveNext(target, options);
      }
    } catch (err) {
      return failCallback(cb, err);
    }
    return cb.promise();
  };
     
  /**
   * Resolve the ORD and get the resolved Object from the ORD Target.
   * 
   * This method calls {@link baja.Ord#resolve} and calls `get` on the ORD 
   * Target to pass the object onto the `ok` function callback.
   * 
   * For more information on how to use this method please see 
   * {@link baja.Ord#resolve}.
   * 
   * @see module:baja/ord/OrdTarget#resolve
   *
   * @param {Object} [obj]
   * @returns {Promise} a promise that will be resolved with the value specified
   * by the ORD.
   * @example
   *   <caption>Resolve/get an ORD</caption>
   *   baja.Ord.make("service:baja:UserService|slot:jack").get({ lease: true })
   *     .then(function (user) {
   *       baja.outln(user.get('fullName'));
   *     })
   *     .catch(function (err) {
   *       baja.error('ORD failed to resolve: ' + err);
   *     });
   */
  Ord.prototype.get = function (obj) {
    var oldObj = obj;
    obj = objectify(obj, "ok"); 
    if (typeof oldObj === "function" || arguments.length === 0) {
      obj.lease = true;
    }      
       
    obj.cb = new Callback(obj.ok, obj.fail);
    obj.cb.addOk(function (ok, fail, target) {
      ok.call(this, target.getObject());
    }); 
  
    this.resolve(obj);
    
    return obj.cb.promise();
  };
  
  /**
   * Return a normalized version of the ORD.
   *
   * @returns {baja.Ord}
   */   
  Ord.prototype.normalize = function () {
    return Ord.make(this.parse().normalize());
  };
    
  /**
   * Relativize is used to extract the relative portion
   * of this ord within an session:
   * 
   * 1. First the ord is normalized.
   * 2. Starting from the left to right, if any queries are
   *    found which return true for `isSession()`, then remove
   *    everything from that query to the left.
   * 
   * @see baja.OrdQuery#isSession
   * @returns {baja.Ord}
   */
  Ord.prototype.relativizeToSession = function () {
    var list = this.parse().normalize(),
        newList = new baja.OrdQueryList();
        
    for (var i = 0, len = list.size(); i < len; ++i) {
      var q = list.get(i);
      
      if (!q.isSession() && !q.isHost()) {
        newList.add(q);
      }
    } 
    return Ord.make(newList);
  };

  /**
   * Slot and file path ord queries may contain "../" to do relative traversal up the tree.  If
   * there is more than one backup, the ord will contain "/../", which will be replaced by the
   * browser within a URL by removing other sections.  For example, https://127.0.0.1/a/b/c/d/../e/f
   * is converted to https://127.0.0.1/a/b/c/e/f and https://127.0.0.1/a/b/c/d/../../e/f is
   * converted to https://127.0.0.1/a/b/c/f.  This will result in unintended behavior in subsequent
   * ord resolution with that URL.  Therefore, all but the last "../" is replaced with {@code
   * "<schema>:..|"}.  This function replicates the behavior of BOrdUtil#replaceBackups.
   *
   * @param {baja.Ord} ord ord that is searched for "../" backups
   * @returns {baja.Ord} the original ord if no changes are necessary or an updated ord with the
   * necessary replacements
   * @since Niagara 4.3U1
   */
  Ord.replaceBackups = function (ord) {
    var queries = ord.parse();
    var newQueries = new baja.OrdQueryList();
    var remakeOrd = false;
    for (var i = 0; i < queries.size(); ++i) {
      var query = queries.get(i);

      // In BajaScript, only the slot and virtual schemes have a backup depth.  The backup depth is
      // only a problem if greater than one because then the ord contains one or more "/../".
      if (query.getScheme() instanceof baja.SlotScheme && query.getBackupDepth() > 1) {
        remakeOrd = true;

        // Replace all but one backup with a new slot path with ".." as the body
        for (var j = 0; j < query.getBackupDepth() - 1; ++j) {
          newQueries.add(query.makeSlotPath('..'));
        }

        // Remove all the "/.." from the body of the original OrdQuery.  For example,
        // slot:../../../abc/def becomes slot:../abc/def
        var newBody = query.getBody().replace(/\/\.\./g, '');
        newQueries.add(query.makeSlotPath(newBody));
      } else {
        newQueries.add(query);
      }
    }

    if (remakeOrd) {
      ord = baja.Ord.make(newQueries);
    }

    return ord;
  };
  
  /**
   * Return the ORD as a URI that can be used in a browser.
   *
   * @returns {String}
   */
  Ord.prototype.toUri = function () {
    // Handle whether there is a hyperlink to another Station by guessing 
    // what's available from fox. For example...
    // ip:{ipAddress}|:fox{s}|station:|slot:/ -> http{s}://{ipAddress}/ord/station:%7Cslot:/
    var ord = this.normalize(),
      uri = String(ord),
      res = /^ip:([^|]+)\|fox(s)?:.*/.exec(uri),
      prefix = res ? ("http" + (res[2] || "") + "://" + res[1]) : "";

    // If the ORD isn't already an HTTP(S) ORD then process it.
    if (!uri.match(/^http/i)) {
      ord = this.relativizeToSession();
      ord = Ord.replaceBackups(ord);
      uri = encodeURI(String(ord)).replaceAll(/[#;]/g, function (match) {
        return encodeURIComponent(match);
      });
      uri = "/ord/" + uri;
    }

    return prefix + uri;
  };

  /**
   * Substitute all variables in the ORD from the given variable map.
   * @param {baja.Facets|Object} variables a Facets or object literal containing
   * variable names and their values
   * @returns {baja.Ord} an ORD with the variables substituted in
   * @throws {Error} if a variable name is invalid or empty, or if a variable
   * declaration is malformed
   * @since Niagara 4.10
   */
  Ord.prototype.substitute = function (variables) {
    if (!baja.hasType(variables, 'baja:Facets')) {
      variables = baja.Facets.make(variables || {});
    }
    return baja.Ord.make(String(this).replace(new RegExp(VARIABLE_REGEX, 'g'), function (match, key) {
      validateVariableMatch(match, key);
      return variables.get(key, match);
    }));
  };

  /**
   * @returns {boolean} true if this ORD has any variables present
   * @throws {Error} if a variable name is invalid or empty, or if a variable
   * declaration is malformed
   * @since Niagara 4.10
   */
  Ord.prototype.hasVariables = function () {
    var match = String(this).match(VARIABLE_REGEX);
    if (match) {
      validateVariableMatch(match[0], match[1]);
      return true;
    } else {
      return false;
    }
  };

  /**
   * @returns {string[]} an array of all variable names present in this ORD
   * @throws {Error} if a variable name is invalid or empty, or if a variable
   * declaration is malformed
   * @since Niagara 4.10
   */
  Ord.prototype.getVariables = function () {
    var str = String(this);
    var variables = [];

    var match;
    var regex = new RegExp(VARIABLE_REGEX, 'g');
    while ((match = regex.exec(str))) {
      validateVariableMatch(match[0], match[1]);
      variables.push(match[1]);
    }
    return variables;
  };

  /**
   * @param {string} match the whole regex match, like `$(foo)`
   * @param {string} name the variable name, like `foo`
   */
  function validateVariableMatch(match, name) {
    if (match[match.length - 1] !== ')') {
      throw new Error('Missing closing paren');
    }

    if (!name) {
      throw new Error('Empty variable name');
    }

    var illegalChar = name.match(/[^A-Za-z0-9]/);
    if (illegalChar) {
      throw new Error('Illegal character in variable name: \'' + illegalChar[0] + '\'');
    }
  }

  /**
   * Return the data type symbol.
   *
   * @returns {String} the Symbol used for encoding this data type (primarily 
   * used for facets).
   */
  Ord.prototype.getDataTypeSymbol = function () {
    return "o";
  };

  function failCallback(cb, err) {
    if (typeof err === 'string') { err = new Error(err); }
    cb.fail(err);
    return cb.promise();
  }
  
  return Ord;
});