/**
* @copyright 2015 Tridium, Inc. All Rights Reserved.
* @author Gareth Johnson
*/
/*global niagara:false*/
/**
* Defines {@link baja.Ord}.
* @module baja/ord/Ord
*/
define([
"bajaPromises",
"bajaScript/sys",
"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. (If only `base` is present, and
* `child` is omitted or null, then just the `base` will be used.)
* 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) {
var base = ord.base;
var child = ord.child;
ordString = String(base);
if (child && !baja.Ord.make(child).isNull()) {
ordString += '|' + child;
}
} 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');
const that = this,
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,
full = bajaDef(obj.full, false),
cursor = obj.cursor;
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) {
const resolvedObj = target.getComponent() || target.getObject();
ok.call(resolvedObj, target);
});
if (subscriber !== null) {
// If we need to subscribe using a Subscriber once the Component is resolved...
cb.addOk((ok, fail, target) => subscribeTarget(target, subscriber).then(() => ok(target), fail));
}
if (lease) {
// If we need to lease once the Component is resolved...
cb.addOk((ok, fail, target) => leaseTarget(target, leaseTime).then(() => ok(target), fail));
}
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");
}
const ordQueries = that.parse();
if (ordQueries.isEmpty()) {
return failCallback(cb, "Cannot resolve null ORD: " + that.toString());
}
const canResolveLocally = ordQueries.isClientResolvable() && !obj.forceServerResolve;
const target = new OrdTarget();
target.object = base;
target.ord = that;
const options = {
full: full,
callback: cb,
queries: ordQueries,
ord: that,
cursor: cursor,
sp: obj.$sp
};
// Normalize using the Context parameter that indicates it's resolution time
ordQueries.normalize(baja.OrdQuery.RESOLVING_ORD_CX);
if (canResolveLocally) {
// Resolve the ORD locally. 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 there will be a delay before the ORD can process further
ordQueries.getCursor().resolveNext(target, options);
} else {
// If there are ORD Schemes that aren't implemented in BajaScript then we
// simply make a network call and resolve the ORD Server side
const newTarget = new OrdTarget(target);
cb.addOk(function (ok, fail, responseFromOrdChannel) {
const {
c: cursorResultEncodings,
f: facetsEncoding,
o: resolutionResultEncoding,
sp: ordChannelRedirectedToLocalOrd,
p: permissions
} = responseFromOrdChannel;
const {
v: encodedPermissions,
cr: canRead,
cw: canWrite,
ci: canInvoke
} = permissions || {};
newTarget.$serverPermissionsStr = encodedPermissions;
newTarget.$canRead = !!canRead;
newTarget.$canWrite = !!canWrite;
newTarget.$canInvoke = !!canInvoke;
if (!resolutionResultEncoding) {
//NCCB-55298
const isClientUnableToResolve = obj.$sp === false;
if (isClientUnableToResolve) {
return failCallback(cb, new Error("Server instructed us to resolve this ORD in the client, but we can't"));
}
}
// Decode the result
Promise.all([
resolutionResultEncoding && baja.bson.decodeAsync(resolutionResultEncoding, baja.$serverDecodeContext)
.then((value) => {
return Promise.resolve(cursorResultEncodings && canCursor(value) && doCursor(value, cursor, cursorResultEncodings))
.then(() => value);
}),
facetsEncoding && baja.Facets.DEFAULT.decodeAsync(facetsEncoding)
])
.then(([ valueResolvedFromStation, serverFacets ]) => {
newTarget.object = valueResolvedFromStation;
newTarget.$serverFacets = serverFacets;
// 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 (ordChannelRedirectedToLocalOrd) {
return determineLocalRedirectOrd(responseFromOrdChannel, that)
.then((localRedirectOrd) => {
function localTargetResolved(localTarget) {
/*
at first glance this looks wasteful because we're resolving a value *plus* doing
a redirect to a local component. but ORDs also resolve to non-components like
Strings. so if we do a server resolve to, say, a String slot of a component we
look up via neql, the server can respond with "here's the string you asked for,
but it lives on a mounted component you can actually find over *here* in your
component space."
*/
if (valueResolvedFromStation !== undefined) {
localTarget = new OrdTarget(localTarget);
localTarget.object = valueResolvedFromStation;
}
if (serverFacets) {
localTarget.$serverFacets = serverFacets;
}
ok(localTarget);
}
localRedirectOrd.resolve({
ok: localTargetResolved, fail, lease, leaseTime, subscriber, full, $sp: false
});
});
} else {
// Finished iterating so just make the callback
ok(newTarget);
}
}, fail);
});
// If Cursor information is defined, ensure we set some defaults
if (cursor) {
cursor.limit = cursor.limit || 10;
cursor.offset = cursor.offset || 0;
}
// Make the network call to resolve the complete ORD Server side
baja.comm.resolve(that, baseOrd, cb, 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] if no obj provided, this will call baja.Ord#resolve
* with lease true.
* @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|wss)?:.*/.exec(uri),
prefix = res ? ("http" + (res[2] ? "s" : "") + "://" + 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, (match) => 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();
}
/**
* If we request an ORD resolution from the OrdChannel, it can sometimes determine that the result
* is a component that should be resolved in a local component space, and send a response that
* just redirects us to a local ORD. Figure out that redirect ORD and verify that we may, in fact,
* resolve it locally.
*
* @param {object} responseFromOrdChannel
* @param {baja.Ord} originalOrd if we can't resolve it locally, something went wrong - but we'll
* send this same original ORD back up to the OrdChannel for re-resolution, with a flag indicating
* that redirecting to a local ComponentSpace is *not* allowed.
* @returns {Promise.<baja.Ord>}
*/
function determineLocalRedirectOrd(responseFromOrdChannel, originalOrd) {
const {
cs: redirectComponentSpaceOrd,
pp: redirectPropertyPath,
sp: redirectSlotPath
} = responseFromOrdChannel;
return verifyComponentSpaceIsUsable(redirectComponentSpaceOrd, String(originalOrd))
.then(() => {
let ordStr = redirectComponentSpaceOrd + "|slot:" + redirectSlotPath;
if (redirectPropertyPath) {
ordStr += '|slot:' + redirectPropertyPath.join('/');
}
return Ord.make(ordStr);
})
.catch(() => originalOrd);
}
/**
* Determines if the component space the server returned is one that the client can resolve.
* @private
* @param {String} componentSpaceOrd the component space ORD returned from the server.
* @param {String} currOrd the current ORD value that THIS is using
* @returns {Promise} to be resolved if the component space ORD from the server is usable,
* otherwise rejected
*/
function verifyComponentSpaceIsUsable(componentSpaceOrd, currOrd) {
//If the componentSpaceOrd === the currOrd (that) then we are stuck in a loop and need to exit
// by sending back the ord we got from the server instead of trying to resolve it again.
if (componentSpaceOrd === currOrd) {
return Promise.resolve();
}
return baja.Ord.make(componentSpaceOrd).get()
.then((componentSpace) => {
const csType = String(componentSpace.getType());
if (csType !== 'baja:ComponentSpace' && csType !== 'baja:VirtualComponentSpace') {
throw new Error('Your resp.cs was not a usable component space');
}
});
}
function subscribeTarget(target, subscriber) {
const comp = target.getComponent();
if (comp !== null && comp.isMounted()) {
return subscriber.subscribe({ comps: [ comp ] });
} else {
return Promise.resolve();
}
}
function leaseTarget(target, leaseTime) {
const comp = target.getComponent();
if (comp !== null && comp.isMounted()) {
return comp.lease({ time: leaseTime, importAsync: true });
} else {
return Promise.resolve();
}
}
function canCursor(value) {
return baja.hasType(value) && typeof value.cursor === "function";
}
function doCursor(cursorableValue, cursorArgs, cursorResultEncodings) {
cursorArgs.$cursorBsonArray = cursorResultEncodings;
return cursorableValue.cursor(cursorArgs);
}
return Ord;
});