/**
* OData(v4) provider for UnityBase EE. After call to OData.registerEndpoint UB enityties are accessible using OData protocol.
*
var OData = require('@ub-e/odata');
var endpoint = OData.registerEndpoint({
endpointName: 'ODataV4',
namespace: 'autotest',
requireAuth: false,
skipOptimisticLock: true,
entitySetMapping: {
ODataRef: App.domain.byName('tst_ODataRef'),
ODataSimple: App.domain.byName('tst_ODataSimple')
}
});
*
* See {@tutorial odata} tutorial for details.
*
* We strongly recommend to use a `ubql` because it is easier, more convenient and faster than the `OData`
*
* @tutorial odata
* @module @ub-e/odata
*/
const querystring = require('querystring'),
parser = require('odatav4-parser'),
url = require("url"),
Router = require('./router'),
XMLBuilder = require('xmlbuilder');
var endpoints = {};
/**
* Register OData endpoint
* @param {Object} props
* @param {String} props.endpointName
* @param {String} props.namespace OData metadata namespace (application name, for example)
* @param {String} [props.requireAuth=true]
* @param {Boolean} [props.skipOptimisticLock=false] If true will skip Optimistic Lock even for entities with mStorage.simpleAudit: true. If false - will use ETag value as a mi_modifyDate
* @param {Object<String, TubEntity>} [props.entitySetMapping] key-value, where keys is entitySet name & values is UB entity. If empty - filled from all domain entities
* @return {ODataEndpoint}
*/
module.exports.registerEndpoint = function registerEndpoint(props) {
var endpoint;
if (endpoints[props.endpointName])
throw new Error('Endpoints already registered');
endpoints[props.endpointName] = endpoint = new ODataEndpoint(props);
App.registerEndpoint(props.endpointName, endpoint.handle.bind(endpoint), props.requireAuth !== false);
return endpoint;
};
function ODataError(message, code) {
this.name = 'ODataError';
this.code = code || 500;
this.message = message;
this.stack = (new Error()).stack;
}
ODataError.prototype = new Error();
ODataError.prototype.constructor = ODataError;
/**
* Construct new ODataEndpoint
* @param {Object} props
* @constructor
*/
function ODataEndpoint(props){
var l, i, ubEntity, entities;
this.endpointName = props.endpointName;
this.namespace = props.namespace;
this.entitySetMapping = {};
this.serviceUrl = App.serverURL + '/' + props.endpointName;
this.doSkipOptimisticLock = props.skipOptimisticLock || false;
if (props.entitySetMapping){
_.defaults(this.entitySetMapping, props.entitySetMapping)
} else {
l = App.domain.count;
entities = App.domain.items;
for (i=0; i<l; i++){
ubEntity = entities[i];
this.entitySetMapping[ubEntity.name] = ubEntity;
}
}
}
/**
* Main OData endpoint handler. Will parse the request route and call sub-handler.
* @param {THTTPRequest} req
* @param {THTTPResponse} res
*/
ODataEndpoint.prototype.handle = function(req, res){
if (!this.router) {
this.router = new Router();
this._initializeRoutes();
}
this.router.dispatch(req, res);
};
/**
* Initialize router for OData protocol
* @private
*/
ODataEndpoint.prototype._initializeRoutes = function () {
var self = this;
this.router.get("/\$metadata", function(req, res, params) {
self.metadata(req, res, params);
});
this.router.get("/:entitySet/\$count/", function(req, res, params) {
params.$count = true;
self.query(req, res, params);
});
this.router.get("/:entitySet\\(:id\\)", function(req, res, params) {
self.queryByID(req, res, params);
});
this.router.get("/:entitySet", function(req, res, params) {
self.query(req, res, params);
});
this.router.get("/", function(req, res, params) {
self.collections(req, res, params);
});
this.router.post("/:entitySet", function(req, res, params) {
self.insert(req, res, params);
});
this.router.patch("/:entitySet\\(:id\\)", function(req, res, params) {
self.update(req, res, params);
});
this.router.delete("/:entitySet\\(:id\\)", function(req, res, params) {
self.remove(req, res, params);
});
this.router.error(function(req, res, error) {
function def(e) {
//self.emit("odata-error", e);
res.statusCode = error.code || 500;
//res.writeHead(error.code || 500, {'Content-Type': 'application/json'});
res.writeEnd({
"error": {
"code": error.code || 500,
"message": e.message,
"target": req.url,
"details": []
},
"innererror": { }
});
}
//if (self.cfg.error) {
// self.cfg.error(req, res, error, def);
//}
//else {
//console.error(error);
def(error);
throw error;
//}
});
};
// see http://docs.oasis-open.org/odata/odata/v4.0/errata02/os/complete/part3-csdl/odata-v4.0-errata02-os-part3-csdl-complete.html#_The_edm:Documentation_Element
var ub2ODataTypeMapping = {};
ub2ODataTypeMapping[TubAttrDataType.String] = 'Edm.String';
ub2ODataTypeMapping[TubAttrDataType.Int] = 'Edm.Int32';
ub2ODataTypeMapping[TubAttrDataType.BigInt] = 'Edm.Int64';
ub2ODataTypeMapping[TubAttrDataType.Float] = 'Edm.Double';
ub2ODataTypeMapping[TubAttrDataType.Currency]= 'Edm.Decimal';
// Edm.Boolean wait for true/false during serialization, but actually in UB boolean represented as 0/1 so
// TODO annotate in metadata
ub2ODataTypeMapping[TubAttrDataType.Boolean] = 'Edm.Byte';
ub2ODataTypeMapping[TubAttrDataType.DateTime]= 'Edm.DateTimeOffset'; // always in UTC0
ub2ODataTypeMapping[TubAttrDataType.Text] = 'Edm.String';
ub2ODataTypeMapping[TubAttrDataType.ID] = 'Edm.Int64';
ub2ODataTypeMapping[TubAttrDataType.Entity] = 'Edm.Int64';
ub2ODataTypeMapping[TubAttrDataType.Document]= 'Edm.Stream'; //?? actually info about there stream are. Client must use getDocument to retrieve actual value
ub2ODataTypeMapping[TubAttrDataType.Many]= 'Edm.String';
ub2ODataTypeMapping[TubAttrDataType.TimeLog]= 'Edm.Int32';
ub2ODataTypeMapping[TubAttrDataType.Enum] = 'Edm.String';
ub2ODataTypeMapping[TubAttrDataType.BLOB]= 'Edm.Binary';
ub2ODataTypeMapping[TubAttrDataType.Date]= 'Edm.Date'; // actually date without time
/**
* Metadata /$metadata request
* @param {THTTPRequest} req
* @param {THTTPResponse} res
* @param {Object} params
*/
ODataEndpoint.prototype.metadata = function(req, res, params){
var me = this,
domain = me.entitySetMapping,
schemas = [];
//// EnumType's - not supported by OData: OData require enum value to be Int32. UB store enum values as a string
//var enums = UB.Repository('ubm_enum').attrs(['eGroup','code', 'name']).selectAsObject();
//var gruppedEnums = _.groupBy(enums, 'eGroup');
//
//_.forEach(gruppedEnums, function(enumItems, enumCode){
// var enumType = {
// "EnumType": {
// "@Name": enumCode,
// "#list": []
// }
// },
// list = enumType.EnumType['#list'];
// enumItems.forEach(function(enumItem){
// list.push({"Member": {"@Name": enumItem.name, "@Value": enumItem.code}})
// });
// schemas.push(enumType);
//});
var resultEntitySets = {};
//EntityType's
_.forEach(domain, function(entity, entitySetName){
var
entityName = entity.name,
attributes = JSON.parse(entity.attributes.asJSON);
var entityType = {
"EntityType": {
"@Name": entity.name
}
};
if (entity.attributes.byName('ID')){
// primary key (always ID)
entityType["EntityType"]["Key"] = { // Must be BEFORE attrbutes list. WTF!
"PropertyRef": {
"@Name": "ID"
}
};
} else {
console.warn('Skip entity"', entityName, 'withiut ID attribute');
return;
}
if (entity.attributes.count) {
var props = entityType.EntityType['#list'] = [];
_.forEach(attributes, function (attrDef, attrName) {
var propDef;
var simpleType = ub2ODataTypeMapping[attrDef.dataType];
if (simpleType) {
propDef = {"Property": {"@Name": attrName, "@Type": simpleType}};
if (attrDef.allowNull === false) {
propDef["Property"]["@Nullable"] = false
}
// add enum via annotation. TODO terms vocabulary must be defined first
//if (attrDef.dataType === TubAttrDataType.Enum){ // add annotation for enum
// propDef["Property"].Annotation = {
// "@Term": "ENUM",
// "#list": []
// }
// eList = propDef["Property"].Annotation['#list'];
// _.forEach(gruppedEnums[attrDef.enumGroup], function(eDef){
// eList.push({
// "EnumMember": eDef.code
// })
// })
//}
props.push(propDef);
// Add NavigationProperty with ReferentialConstraint
if (attrDef.dataType === TubAttrDataType.Entity) {
propDef = {
"NavigationProperty": {
"@Name": attrName + '2' + attrDef.associatedEntity, // force create virtual OData attribute
"@Type": me.namespace + "." + attrDef.associatedEntity,
"@Partner": entityName,
"ReferentialConstraint": {
"@Property": attrName, "@ReferencedProperty": attrDef.associationAttr || "ID"
}
}
};
props.push(propDef);
}
} else {
console.error(entityName, attrName, ': invalid property type for OData')
}
});
}
schemas.push(entityType);
resultEntitySets[entitySetName] = entity;
});
// EntitySets (in somle case - direct mapping to UB entities)
var container = {
"EntityContainer": {
"@Name": "Context",
"#list": []
}
},
list = container.EntityContainer['#list'];
_.forEach(resultEntitySets, function(entity, entitySetName){
list.push({
"EntitySet": {
"@EntityType": me.namespace + "." + entity.name,
"@Name": entitySetName
}
});
});
schemas.push(container);
var XMLMetadata = XMLBuilder.create({
"edmx:Edmx": {
"@xmlns:edmx": "http://docs.oasis-open.org/odata/ns/edmx",
"@Version": "4.0",
"edmx:DataServices": {
"Schema": {
"@xmlns": "http://docs.oasis-open.org/odata/ns/edm",
"@Namespace": me.namespace,
"#list": schemas
}
}
}
}).end({pretty: true});
res.statusCode = 200;
res.writeHead('Content-Type: application/xml;charset=utf-8\r\nDataServiceVersion: 4.0\r\nOData-Version: 4.0');
res.writeEnd(XMLMetadata);
};
/**
* Write "Resource not found" error back to caller
* @param {THTTPRequest} req
* @param {THTTPResponse} res
* @param {Object} params
*/
ODataEndpoint.prototype.notFoundError = function(req, res, params){
res.writeEnd(['<m:error xmlns:m="http://docs.oasis-open.org/odata/ns/metadata"><m:code/><m:message>Resource not found for the segment \'',
params.entitySet,
"'.</m:message> </m:error>"
].join('')
);
res.writeHead('Content-Type: application/xml;charset=utf-8');
res.statusCode = 404;
};
/**
* Check entity set exist in entitySetMapping and return entity or write notFound resp and return null
* @param {THTTPRequest} req
* @param {THTTPResponse} res
* @param {Object} params
* @param {String} [method] if passed check ELS
* @return {TubEntity|null}
*/
ODataEndpoint.prototype.checkEntitySet = function(req, res, params, method){
var me = this,
domain = me.entitySetMapping,
entity = domain[params.entitySet];
if (!entity){
me.notFoundError(req, res, params)
}
if (method && !App.els(entity.name, method)){
throw new ODataError('Access to ' + entity.name + '.select is forbidden', 403);
}
return entity;
};
/**
* Single instance request
* @param {THTTPRequest} req
* @param {THTTPResponse} res
* @param {Object} params
*/
ODataEndpoint.prototype.queryByID = function(req, res, params){
var
me = this, entity;
entity = me.checkEntitySet(req, res, params);
console.debug('QueryByID with params:', params);
if (entity){
var record = UB.Repository(entity.name).attrs('*').where('ID', '=', parseInt(params.id, 10)).selectAsObject()[0];
if (!record) {
me.notFoundError(req, res, params)
} else {
//"@odata.context": "http://services.odata.org/V4/OData/(S(03ofpxjklb2w5xahdzlbin3b))/OData.svc/$metadata#Products/$entity"
//record["@odata.context"] = App.serverURL + '/' + me.endpointName + '/$metadata#' + params.entitySet + '/$entity';
res.writeEnd(record);
if (record.mi_modifyDate){
res.writeHead("ETag: " + new Date(record.mi_modifyDate).getTime());
}
res.statusCode = 200;
}
}
};
/*!
* Simple OData $filter expressions. Example:
* { type: 'eq', left: { type: 'property', name: 'code' }, right: { type: 'literal', value: 'code001' } }
*/
var isSimpleExpr = {'lt': true, 'gt': true, 'le': true, 'ge': true, 'eq': true, 'ne': true};
var booleanFunc2Args = {'contains': 'contains', 'not contains': 'notContains', 'startwith': 'startwith', 'not startwith': 'notStartWith'};
var booleanFunc1Arg ={'length': 'length', 'tolower': 'lower', 'toupper': 'upper', 'day': 'day', 'month':'month', 'year': 'year', 'round': 'round', 'floor': 'floor', 'ceiling': 'ceiling'};
//test case
// http://127.0.0.1:888/autotest/ODataV4/tst_document?$orderby=code+desc&$top=20&$select=ID,code,docDate,description&$filter=(contains(code,%27Code%27))+and+length(description)+ge+10&$count=true
/**
* Single instance request
* @param {THTTPRequest} req
* @param {THTTPResponse} res
* @param {Object} params
*/
ODataEndpoint.prototype.query = function(req, res, params){
var
urlParams = req.decodedParameters,
entity, repo, data, prm,
withTotal=false,
haveORCondition = false,
oFilters;
entity = this.checkEntitySet(req, res, params, 'select');
console.debug('QUERY request: ', params);
repo = UB.Repository(entity.name);
if (entity){
if (!urlParams){
console.debug('simple request to full entity');
repo.attrs('*');
} else {
var oDataQuery = parser.parse(urlParams);
if(oDataQuery.error) {
throw new Error(oDataQuery.error);
}
if (prm = oDataQuery['$skip']){
repo.start(prm)
}
if (prm = oDataQuery['$top']){
repo.limit(prm)
}
prm = oDataQuery['$select'];
if (_.isArray(prm) && prm.length){
repo.attrs(prm)
} else {
repo.attrs('*');
}
withTotal = oDataQuery['$count'] || (oDataQuery['$inlinecount'] === 'allpages') || params.$count || false;
if (withTotal){
repo.withTotal();
}
prm = oDataQuery['$orderby'];
if (prm){
prm.forEach(function(orderExpr){
var attrName = Object.keys(orderExpr)[0];
repo.orderBy(attrName, orderExpr[attrName]);
});
}
prm = oDataQuery['$filter'];
console.debug('PARSED odata query is:', JSON.stringify(oDataQuery));
if (prm){
if (Array.isArray(prm)) { // in case of one filter $filters is object
oFilters = _.chain(prm).flatten().without('').value(); // gramar for and/or condition will create nested arrays, so we need flatten
} else {
oFilters = [prm]
}
console.debug('FLATTEN odata filters is:', JSON.stringify(oFilters));
// add all conditions to where list. Name of condition is generated as c + idx in array
oFilters.forEach(function(elm, idx){
var attr, val;
if (_.isObject(elm)){
console.debug('Got a simple expr', JSON.stringify(elm));
if (isSimpleExpr[elm.type]){
if (elm.right.type !== 'literal') {
throw new Error('Right side of simple condition must be a literal value', elm);
}
val = elm.right.value;
// {"type":"ge","left":{"type":"functioncall","func":"length","args":[{"type":"property","name":"description"}]},"right":{"type":"literal","value":10}}
if (elm.left.type === 'property') {
attr = elm.right.name;
} else if ((elm.left.type === 'functioncall') && booleanFunc1Arg[elm.left.func] && elm.left.args[0].type === 'property'){
attr = booleanFunc1Arg[elm.left.func] + '([' + elm.left.args[0].name + '])'
} else {
throw new Error('Left side of simple condition must be a either a property or a boolean function with argument of property type', elm);
}
repo.where(attr, elm.type, val, 'c' + idx);
} else if ((elm.type === 'functioncall') && booleanFunc2Args[elm.func]) {
//{"type":"functioncall","func":"contains","args":[{"type":"property","name":"code"},{"type":"literal","value":"e01"}]}
if ((elm.args[0].type === 'property') && (elm.args[1].type === 'literal')) {
repo.where(elm.args[0].name, booleanFunc2Args[elm.func], elm.args[1].value, 'c' + idx);
} else {
throw new Error('Left agr of ' + elm.func + ' function must be attribute & right - a value', elm);
}
} else {
//tolower,toupper, day, month, year, round, floor, ceiling
throw new Error('Unsupported condition type', elm.type);
}
} else {
if (elm === 'or') haveORCondition = true;
}
});
}
if (haveORCondition){
prm = oFilters.reduce(function(predicat, item, itemIndex){
return predicat + (_.isObject(item) ? '[c'+itemIndex + ']' : item);
}, '');
repo.logic(prm);
}
}
}
var store = repo.selectAsStore();
data = store.asJSONObject;
if (withTotal){
var totalRecCount = store.totalRowCount;
var prefix = (oDataQuery['$inlinecount'] === 'allpages') ? '' : '@';
res.write('{"' + prefix + 'odata.count":' + totalRecCount + ',"value":');
} else {
res.write('{"value":');
}
store.freeNative();
res.write(data);
res.writeEnd('}');
res.statusCode = 200;
};
/**
* Root OData request - must return a EntitySet collection
* @param {THTTPRequest} req
* @param {THTTPResponse} res
* @param {Object} params
*/
ODataEndpoint.prototype.collections = function(req, res, params){
var model = this.entitySetMapping;
var collections = Object.keys(model).map(function(eSet){
return {
"kind": "EntitySet",
"name": eSet,
"url": eSet
};
});
res.statusCode = 200;
res.writeEnd({
"@odata.context": this.serviceUrl + "/$metadata",
"value": collections
});
};
ODataEndpoint.prototype.insert = function(req, res, params){
var
entity = this.checkEntitySet(req, res, params, 'insert'),
execParams = JSON.parse(req.read()),
i;
console.debug('Insert OData request: ', execParams);
var store = new TubDataStore(entity);
store.run('insert', {execParams: execParams});
store.currentDataName = 'selectAfterInsert';
if (!store.eof) {
i = store.fieldIndexByName('mi_modifyDate');
if (i >= 0) res.writeHead("ETag: " + new Date(store.get(i)).getTime());
}
res.statusCode = 204;
};
/**
* PATCH OData request. Support optimistic lock using E-Tag
* @param {THTTPRequest} req
* @param {THTTPResponse} res
* @param {Object} params
*/
ODataEndpoint.prototype.update = function(req, res, params){
//TODO 'If-Match' header! 412 resp in case not match
var id = parseInt(params.id, 10);
if (!id){
throw new ODataError('Invalid key value: ', params.id);
}
var entity = this.checkEntitySet(req, res, params, 'update');
var headers = req.headers;
console.debug('Got headers:', headers);
console.debug('UPDATE request: ', params);
var execParams = JSON.parse(req.read());
execParams.ID = id;
console.debug('Update OData request: ', execParams);
var store = new TubDataStore(entity);
if (this.doSkipOptimisticLock){
store.run('update', {
__skipOptimisticLock: true,
execParams: execParams
})
} else {
store.run('update', {execParams: execParams});
}
store.freeNative();
res.statusCode = 204;
//if (headers.return) return=minimal
//204 - no content
//return=representation
// 200 body
};
ODataEndpoint.prototype.remove = function(req, res, params){
//TODO 'If-Match' header! 412 resp in case not match
var id = parseInt(params.id, 10);
if (!id){
throw new ODataError('Invalid key value: ', params.id);
}
var entity = this.checkEntitySet(req, res, params, 'delete');
var headers = req.headers;
console.debug('Got headers:', headers);
console.debug('DELETE request: ', params);
var store = new TubDataStore(entity);
if (this.doSkipOptimisticLock){
store.run('delete', {
__skipOptimisticLock: true,
execParams: {ID: id}
})
} else {
store.run('delete', {execParams: {ID: id}});
}
res.statusCode = 204;
};