/* Copyright (c) 2010, Sage Software, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function() {
"use strict";
var Sage = window.Sage,
S = Sage,
C = Sage.namespace('Sage.SData.Client'),
isDefined = function(value) { return typeof value !== 'undefined'; },
unwrap = function(value) {
return value && value['#text']
? value['#text']
: value;
},
nsRE = /^(.+?):(.*)$/;
Sage.SData.Client.SDataService = Sage.Evented.extend({
uri: null,
useCredentialedRequest: false,
useCrossDomainCookies: false,
userAgent: 'Sage',
userName: false,
password: '',
batchScope: null,
timeout: 0,
cache: false,
cacheParam: '_t',
_etags: {},
maxGetUriLength: 2000,
constructor: function(options, userName, password) {
// pass the first argument to the base class; will only have an effect if the argument
// is an object and has a `listeners` property.
this.base.call(this, options);
this.setUri(options);
// Other options
if (isDefined(options.includeContent)) this.uri.setIncludeContent(options.includeContent);
if (isDefined(options.version)) this.uri.setVersion(options.version);
if (isDefined(options.cache)) this.cache = options.cache;
if (isDefined(options.cacheParam)) this.cacheParam = options.cacheParam;
// Support for the new compact mode in Saleslogix 8.1 and higher
if (isDefined(options.compact)) this.uri.setCompact(options.compact);
// Username/password use options first, constructor params will take priority
if (isDefined(options.userName)) this.userName = options.userName;
if (isDefined(options.password)) this.password = options.password;
if (isDefined(userName)) this.userName = userName;
if (isDefined(password)) this.password = password;
if (isDefined(options.json)) this.json = options.json;
if (isDefined(options.maxGetUriLength)) this.maxGetUriLength = options.maxGetUriLength;
if (isDefined(options.timeout)) this.timeout = options.timeout;
if (isDefined(options.useCredentialedRequest)) this.useCredentialedRequest = options.useCredentialedRequest;
if (isDefined(options.useCrossDomainCookies)) this.useCrossDomainCookies = options.useCrossDomainCookies;
this.addEvents(
'beforerequest',
'requestcomplete',
'requestexception',
'requestaborted',
'requesttimeout'
);
},
setUri: function(options) {
if (isDefined(options.uri)) {
this.uri = new Sage.SData.Client.SDataUri(options.uri);
} else {
this.uri = new Sage.SData.Client.SDataUri();
var parsed = this.parseUri(options);
// Parsed URI
if (isDefined(parsed.serverName)) this.uri.setHost(parsed.serverName);
if (isDefined(parsed.virtualDirectory)) this.uri.setServer(parsed.virtualDirectory);
if (isDefined(parsed.applicationName)) this.uri.setProduct(parsed.applicationName);
if (isDefined(parsed.contractName)) this.uri.setContract(parsed.contractName);
if (isDefined(parsed.dataSet)) this.uri.setCompanyDataset(parsed.dataSet);
if (isDefined(parsed.port)) this.uri.setPort(parsed.port);
if (isDefined(parsed.protocol)) this.uri.setScheme(parsed.protocol);
}
return this;
},
parseUri: function(options) {
var result = {},
url = typeof options === 'object'
? options['url']
: options;
var parsed = (typeof window.parseUri === 'function') && window.parseUri(url);
if (parsed)
{
if (parsed.host) result.serverName = parsed.host;
var path = parsed.path.split('/').slice(1);
// ["sdata", "slx", "dynamic", "-", "accounts"]
if (path[0]) result.virtualDirectory = path[0];
if (path[1]) result.applicationName = path[1];
if (path[2]) result.contractName = path[2];
if (path[3]) result.dataSet = path[3];
if (parsed.port) result.port = parseInt(parsed.port);
if (parsed.protocol) result.protocol = parsed.protocol;
}
if (typeof options === 'object') S.apply(result, options);
return result;
},
isJsonEnabled: function() {
return this.json;
},
enableJson: function() {
this.json = true;
return this;
},
disableJson: function() {
this.json = false;
return this;
},
getVersion: function() {
return this.uri.getVersion();
},
setVersion: function(val) {
this.uri.setVersion(val);
return this;
},
getUri: function() {
///
return this.uri;
},
getUserName: function() {
///
return this.userName;
},
setUserName: function(value) {
this.userName = value;
return this;
},
getPassword: function() {
return this.password;
},
setPassword: function(value) {
this.password = value;
return this;
},
getProtocol: function() {
return this.uri.getScheme();
},
setProtocol: function(value) {
this.uri.setScheme(value);
return this;
},
getServerName: function() {
return this.uri.getHost();
},
setServerName: function(value) {
this.uri.setHost(value);
return this;
},
getPort: function() {
return this.uri.getPort();
},
setPort: function(value) {
this.uri.setPort(value);
return this;
},
getVirtualDirectory: function() {
return this.uri.getServer();
},
setVirtualDirectory: function(value) {
this.uri.setServer(value);
return this;
},
getApplicationName: function() {
return this.uri.getProduct();
},
setApplicationName: function(value) {
this.uri.setProduct(value);
return this;
},
getContractName: function() {
return this.uri.getContract();
},
setContractName: function(value) {
this.uri.setContract(value);
return this;
},
getDataSet: function() {
return this.uri.getCompanyDataset();
},
setDataSet: function(value) {
this.uri.setCompanyDataset(value);
return this;
},
getIncludeContent: function() {
return this.uri.getIncludeContent();
},
setIncludeContent: function(value) {
this.uri.setIncludeContent(value);
return this;
},
getCompact: function() {
return this.uri.getCompact();
},
setCompact: function(value) {
this.uri.setCompact(value);
return this;
},
setMaxGetUriLength: function(value) {
this.maxGetUriLength = value;
return this;
},
getMaxGetUriLength: function() {
return this.maxGetUriLength;
},
getUserAgent: function() {
return this.userAgent;
},
setUserAgent: function(value) {
this.userAgent = value;
return this;
},
registerBatchScope: function(scope) {
this.batchScope = scope;
},
clearBatchScope: function(scope) {
this.batchScope = null;
},
createBasicAuthToken: function() {
return 'Basic ' + Base64.encode(this.userName + ":" + this.password);
},
createHeadersForRequest: function(request) {
var headers = {
/* 'User-Agent': this.userAgent */ /* 'User-Agent' cannot be set on XmlHttpRequest */
'X-Authorization-Mode': 'no-challenge'
};
if (this.userName && !this.useCredentialedRequest)
headers['Authorization'] = headers['X-Authorization'] = this.createBasicAuthToken();
return headers;
},
extendAcceptRequestHeader: function(value, extension) {
if (value) return value.split(/\s*,\s*/).join(';' + extension + ',') + ';' + extension;
return value;
},
executeRequest: function(request, options, ajax) {
/// request object
// todo: temporary fix for SalesLogix Dynamic Adapter only supporting json selector in format parameter
if (this.json) request.setQueryArg('format', 'json');
var url = request.build();
var o = S.apply({
async: options.async,
headers: {},
method: 'GET',
url: url,
cache: options.cache || this.cache,
cacheParam: options.cacheParam || this.cacheParam,
etagCache: this._etags[url]
}, {
scope: this,
success: function(response, opt) {
var responseText = response.responseText;
if (response.status === 304) {
responseText = this._etags[url].responseText;
}
var contentType = response.getResponseHeader && response.getResponseHeader('Content-Type');
var feed = this.processFeed(responseText, contentType);
var etag = response.getResponseHeader && response.getResponseHeader('etag');
this._etags[url] = {
etag: etag,
responseText: responseText
};
this.fireEvent('requestcomplete', request, opt, feed);
if (options.success)
options.success.call(options.scope || this, feed);
},
failure: function(response, opt) {
this.fireEvent('requestexception', request, opt, response);
if (options.failure)
options.failure.call(options.scope || this, response, opt);
},
aborted: function(response, opt) {
this.fireEvent('requestaborted', request, opt, response);
if (options.aborted)
options.aborted.call(options.scope || this, response, opt);
},
timeout: function(response, opt) {
this.fireEvent('requesttimeout', request, opt, response);
if (options.timeout) {
options.timeout.call(options.scope || this, response, opt);
}
}
}, ajax);
S.apply(o.headers, this.createHeadersForRequest(request), request.completeHeaders);
if (typeof this.timeout === 'number') {
o.requestTimeout = this.timeout;
}
/* we only handle `Accept` for now */
if (request.extendedHeaders['Accept'])
o.headers['Accept'] = this.extendAcceptRequestHeader(o.headers['Accept'], request.extendedHeaders['Accept']);
if (this.useCredentialedRequest || this.useCrossDomainCookies)
{
o.withCredentials = true;
}
if (this.useCredentialedRequest && this.userName)
{
o.user = this.userName;
o.password = this.password;
}
this.fireEvent('beforerequest', request, o);
/* if the event provided its own result, do not execute the ajax call */
if (typeof o.result !== 'undefined')
{
if (options.success)
options.success.call(options.scope || this, o.result);
return;
}
return C.Ajax.request(o);
},
abortRequest: function(id) {
C.Ajax.cancel(id);
},
readFeed: function(request, options) {
/// request object
options = options || {};
if (this.batchScope)
{
this.batchScope.add({
url: request.build(),
method: 'GET'
});
return;
}
var ajax = {
headers: {
'Accept': this.json
? 'application/json,*/*'
: 'application/atom+xml;type=feed,*/*'
}
};
var builtRequest = request.build();
if (options.httpMethodOverride || (typeof builtRequest === 'string' && builtRequest.length > this.maxGetUriLength))
{
// todo: temporary fix for SalesLogix Dynamic Adapter only supporting json selector in format parameter
// todo: temporary fix for `X-HTTP-Method-Override` and the SalesLogix Dynamic Adapter
if (this.json) request.setQueryArg('format', 'json');
ajax.headers['X-HTTP-Method-Override'] = 'GET';
ajax.method = 'POST';
ajax.body = request.build();
ajax.url = request.build(true); // exclude query
}
return this.executeRequest(request, options, ajax);
},
readEntry: function(request, options) {
/// request object
options = options || {};
if (this.batchScope)
{
var scope = {
url: request.build(),
method: 'GET'
};
var key = request.getResourceKey();
if (typeof key === 'string' && key.length > 0) {
scope.key = key;
}
this.batchScope.add(scope);
return;
}
var o = S.apply({}, {
success: function(feed) {
var entry = feed['$resources'][0] || false;
if (options.success)
options.success.call(options.scope || this, entry);
}
}, options);
return this.executeRequest(request, o, {
headers: {
'Accept': this.json
? 'application/json,*/*'
: 'application/atom+xml;type=entry,*/*'
}
});
},
createEntry: function(request, entry, options) {
options = options || {};
if (this.batchScope)
{
this.batchScope.add({
url: request.build(),
entry: entry,
method: 'POST'
});
return;
}
var o = S.apply({}, {
success: function(feed) {
var entry = feed['$resources'][0] || false;
if (options.success)
options.success.call(options.scope || this, entry);
}
}, options);
var ajax = S.apply({}, {
method: 'POST'
});
if (this.isJsonEnabled())
{
S.apply(ajax, {
body: JSON.stringify(entry),
headers: {
'Content-Type': 'application/json'
}
});
}
else
{
var xml = new XML.ObjTree();
xml.attr_prefix = '@';
S.apply(ajax, {
body: xml.writeXML(this.formatEntry(entry)),
headers: {
'Content-Type': 'application/atom+xml;type=entry',
'Accept': 'application/atom+xml;type=entry,*/*'
}
});
}
return this.executeRequest(request, o, ajax);
},
updateEntry: function(request, entry, options) {
/// request object
options = options || {};
if (this.batchScope)
{
this.batchScope.add({
url: request.build(),
entry: entry,
method: 'PUT',
etag: entry['$etag']
});
return;
}
var o = S.apply({}, {
success: function(feed) {
var entry = feed['$resources'][0] || false;
if (options.success)
options.success.call(options.scope || this, entry);
}
}, options);
var headers = {},
ajax = {
method: 'PUT',
headers: headers
};
// per the spec, the 'If-Match' header MUST be present for PUT requests.
// however, we are breaking with the spec here, on the consumer, to allow it to be OPTIONAL so
// that the provider can decide if it wishes to break with the spec or not.
if (entry['$etag'] && !(options && options.ignoreETag))
headers['If-Match'] = entry['$etag'];
if (this.isJsonEnabled())
{
headers['Content-Type'] = 'application/json';
ajax.body = JSON.stringify(entry);
}
else
{
var xml = new XML.ObjTree();
xml.attr_prefix = '@';
headers['Content-Type'] = 'application/atom+xml;type=entry';
headers['Accept'] = 'application/atom+xml;type=entry,*/*';
ajax.body = xml.writeXML(this.formatEntry(entry));
}
return this.executeRequest(request, o, ajax);
},
deleteEntry: function(request, entry, options) {
/// request object
options = options || {};
if (this.batchScope)
{
this.batchScope.add({
url: request.build(),
method: 'DELETE',
etag: !(options && options.ignoreETag) && entry['$etag']
});
return;
}
var headers = {},
ajax = {
method: 'DELETE',
headers: headers
};
if (entry['$etag'] && !(options && options.ignoreETag))
headers['If-Match'] = entry['$etag'];
return this.executeRequest(request, options, ajax);
},
executeServiceOperation: function(request, entry, options) {
var o = S.apply({}, {
success: function(feed) {
var entry = feed['$resources'][0] || false,
response = entry && entry['response'],
resources = response && response['$resources'],
payload = resources && resources[0];
if (payload && payload['$name'])
{
entry['response'] = {};
entry['response'][payload['$name']] = payload;
}
if (options.success)
options.success.call(options.scope || this, entry);
}
}, options);
var ajax = S.apply({}, {
method: 'POST'
});
if (this.isJsonEnabled())
{
S.apply(ajax, {
body: JSON.stringify(entry),
headers: {
'Content-Type': 'application/json'
}
});
}
else
{
var xml = new XML.ObjTree();
xml.attr_prefix = '@';
S.apply(ajax, {
body: xml.writeXML(this.formatEntry(entry)),
headers: {
'Content-Type': 'application/atom+xml;type=entry',
'Accept': 'application/atom+xml;type=entry,*/*'
}
});
}
return this.executeRequest(request, o, ajax);
},
commitBatch: function(request, options) {
options = options || {};
var items = request.getItems(),
item,
entry,
feed = {
'$resources': []
};
for (var i = 0; i < items.length; i++)
{
item = items[i];
entry = S.apply({}, item.entry); /* only need a shallow copy as only top-level properties will be modified */
if (item.url) entry['$url'] = item.url;
if (item.key) entry['$key'] = item.key;
if (item.etag) entry['$ifMatch'] = item.etag;
if (item.method) entry['$httpMethod'] = item.method;
delete entry['$etag'];
feed['$resources'].push(entry);
}
var ajax = S.apply({}, {
method: 'POST'
});
if (this.isJsonEnabled())
{
S.apply(ajax, {
body: JSON.stringify(feed),
headers: {
'Content-Type': 'application/json'
}
});
}
else
{
var xml = new XML.ObjTree();
xml.attr_prefix = '@';
S.apply(ajax, {
body: xml.writeXML(this.formatFeed(feed)),
headers: {
'Content-Type': 'application/atom+xml;type=feed',
'Accept': 'application/atom+xml;type=feed,*/*'
}
});
}
return this.executeRequest(request, options, ajax);
},
parseFeedXml: function(text) {
var xml = new XML.ObjTree();
xml.attr_prefix = '@';
return xml.parseXML(text);
},
isIncludedReference: function(ns, name, value) {
return value && value.hasOwnProperty('@sdata:key');
},
isIncludedCollection: function(ns, name, value) {
if (value.hasOwnProperty('@sdata:key')) return false;
if (value.hasOwnProperty('@sdata:uri') || value.hasOwnProperty('@sdata:url')) return true;
// attempt to detect if we are dealing with an included relationship collection
var firstChild,
firstValue;
for (var fqPropertyName in value)
{
if (fqPropertyName.charAt(0) === '@') continue;
firstChild = value[fqPropertyName];
break; // will always ever be one property, either an entity, or an array of
}
if (firstChild)
{
if (S.isArray(firstChild))
firstValue = firstChild[0];
else
firstValue = firstChild;
if (firstValue && firstValue.hasOwnProperty('@sdata:key')) return true;
}
return false;
},
convertEntity: function(ns, name, entity, applyTo) {
applyTo = applyTo || {};
applyTo['$name'] = name;
applyTo['$key'] = entity['@sdata:key'];
applyTo['$url'] = entity['@sdata:uri'];
applyTo['$uuid'] = entity['@sdata:uuid'];
for (var fqPropertyName in entity)
{
if (fqPropertyName.charAt(0) === '@') continue;
var hasNS = nsRE.exec(fqPropertyName),
propertyNS = hasNS ? hasNS[1] : false,
propertyName = hasNS ? hasNS[2] : fqPropertyName,
converted = null,
value = entity[fqPropertyName];
if (typeof value === 'object')
{
if (value.hasOwnProperty('@xsi:nil')) // null
{
converted = null;
}
else if (this.isIncludedReference(propertyNS, propertyName, value)) // included reference
{
converted = this.convertEntity(propertyNS, propertyName, value);
}
else if (this.isIncludedCollection(propertyNS, propertyName, value)) // included collection
{
converted = this.convertEntityCollection(propertyNS, propertyName, value);
}
else // no conversion, read only
{
converted = this.convertCustomEntityProperty(propertyNS, propertyName, value);
}
value = converted;
}
applyTo[propertyName] = value;
}
return applyTo;
},
convertEntityCollection: function(ns, name, collection) {
for (var fqPropertyName in collection)
{
if (fqPropertyName.charAt(0) === '@') continue;
var hasNS = nsRE.exec(fqPropertyName),
propertyNS = hasNS ? hasNS[1] : false,
propertyName = hasNS ? hasNS[2] : fqPropertyName,
value = collection[fqPropertyName];
if (S.isArray(value))
{
var converted = [];
for (var i = 0; i < value.length; i++)
converted.push(this.convertEntity(ns, propertyName, value[i]));
return {
'$resources': converted
};
}
else
{
return {
'$resources': [
this.convertEntity(ns, propertyName, value)
]
};
}
break; // will always ever be one property, either an entity, or an array of
}
return null;
},
convertCustomEntityProperty: function(ns, name, value) {
return value;
},
formatEntity: function(ns, entity, applyTo) {
applyTo = applyTo || {};
if (entity['$key']) applyTo['@sdata:key'] = entity['$key'];
// todo: is this necessary? does not appear to be looking at the spec
// if (entity['$url']) applyTo['@sdata:uri'] = entity['$url'];
// note: not using explicit namespaces at this time
for (var propertyName in entity)
{
if (propertyName.charAt(0) === '$') continue;
var value = entity[propertyName];
if (value == null)
{
value = {'@xsi:nil': 'true'};
}
else if (typeof value === 'object' && value.hasOwnProperty('$resources'))
{
// todo: add resource collection support
value = this.formatEntityCollection(ns, value);
}
else if (typeof value === 'object')
{
value = this.formatEntity(ns, value);
}
applyTo[propertyName] = value;
}
return applyTo;
},
formatEntityCollection: function(ns, value) {
var result = {};
for (var i = 0; i < value['$resources'].length; i++)
{
var item = value['$resources'][i],
name = item['$name'],
target = (result[name] = result[name] || []);
target.push(this.formatEntity(ns, value['$resources'][i]));
}
return result;
},
convertEntry: function(entry) {
var result = {};
result['$descriptor'] = entry['title'];
result['$etag'] = entry['http:etag'];
result['$httpStatus'] = entry['http:httpStatus'];
var payload = entry['sdata:payload'];
for (var key in payload)
{
if (key.charAt(0) === '@') continue;
if (payload.hasOwnProperty(key) == false) continue;
var parts = key.split(':'),
ns,
name,
entity = payload[key];
if (parts.length == 2)
{
ns = parts[0];
name = parts[1];
}
else if (parts.length < 2)
{
ns = false;
name = key;
}
else
{
continue;
}
this.convertEntity(ns, name, entity, result);
}
return result;
},
formatEntry: function(entry, excludeNS) {
var result = {};
if (!excludeNS)
{
result['@xmlns:sdata'] = 'http://schemas.sage.com/sdata/2008/1';
result['@xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
result['@xmlns:http'] = 'http://schemas.sage.com/sdata/http/2008/1';
result['@xmlns'] = 'http://www.w3.org/2005/Atom';
}
if (entry['$httpMethod']) result['http:httpMethod'] = entry['$httpMethod'];
if (entry['$ifMatch']) result['http:ifMatch'] = entry['$ifMatch'];
if (entry['$etag']) result['http:etag'] = entry['$etag'];
if (entry['$url']) result['id'] = entry['$url'];
result['sdata:payload'] = {};
result['sdata:payload'][entry['$name']] = {
'@xmlns': 'http://schemas.sage.com/dynamic/2007'
};
this.formatEntity(false, entry, result['sdata:payload'][entry['$name']]);
return {'entry': result};
},
convertFeed: function(feed) {
var result = {}, i;
if (feed['opensearch:totalResults'])
result['$totalResults'] = parseInt(unwrap(feed['opensearch:totalResults']));
if (feed['opensearch:startIndex'])
result['$startIndex'] = parseInt(unwrap(feed['opensearch:startIndex']));
if (feed['opensearch:itemsPerPage'])
result['$itemsPerPage'] = parseInt(unwrap(feed['opensearch:itemsPerPage']));
if (feed['link'])
{
result['$link'] = {};
for (i = 0; i < feed['link'].length; i++)
result['$link'][feed['link'][i]['@rel']] = feed['link'][i]['@href'];
if (result['$link']['self'])
result['$url'] = result['$link']['self'];
}
result['$resources'] = [];
if (S.isArray(feed['entry']))
for (i = 0; i < feed['entry'].length; i++)
result['$resources'].push(this.convertEntry(feed['entry'][i]));
else if (typeof feed['entry'] === 'object')
result['$resources'].push(this.convertEntry(feed['entry']));
return result;
},
formatFeed: function(feed) {
var result = {};
result['@xmlns:sdata'] = 'http://schemas.sage.com/sdata/2008/1';
result['@xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
result['@xmlns:http'] = 'http://schemas.sage.com/sdata/http/2008/1';
result['@xmlns'] = 'http://www.w3.org/2005/Atom';
if (feed['$url']) result['id'] = feed['$url'];
result['entry'] = [];
for (var i = 0; i < feed['$resources'].length; i++)
{
result['entry'].push(this.formatEntry(feed['$resources'][i], true)['entry']);
}
return {'feed': result};
},
processFeed: function(responseText, contentType) {
if (!responseText) return null;
var doc;
if (/application\/json/i.test(contentType) || (!contentType && this.isJsonEnabled()))
{
doc = JSON.parse(responseText);
// doing this for parity with below, since with JSON, SData will always
// adhere to the format, regardless of the User-Agent.
if (doc.hasOwnProperty('$resources'))
{
return doc;
}
else
{
return {
'$resources': [doc]
};
}
}
else
{
doc = this.parseFeedXml(responseText);
// depending on the User-Agent the SIF will either send back a feed, or a single entry
// todo: is this the right way to handle this? should there be better detection?
if (doc.hasOwnProperty('feed'))
return this.convertFeed(doc['feed']);
else if (doc.hasOwnProperty('entry'))
return {
'$resources': [
this.convertEntry(doc['entry'])
]
};
else
return doc;
}
}
});
})();