├── .jshintignore ├── lib ├── database │ ├── datasource.js │ ├── main_database_adapter.js │ └── elasticsearch_adapter.js ├── systemMessage.js ├── message_queue │ ├── messaging_client.js │ ├── kafka_queue.js │ ├── azure_servicebus_queue.js │ └── amqp_queue.js ├── logger │ └── logger.js ├── Admin.js ├── TelepatIndexedLists.js ├── Context.js ├── Delta.js ├── Channel.js ├── User.js ├── TelepatError.js ├── ConfigurationManager.js ├── Application.js ├── Subscription.js └── Model.js ├── LICENSE ├── .gitignore ├── .npmignore ├── .jshintrc ├── utils ├── profiling.js ├── filterbuilder.js └── utils.js ├── package.json ├── index.js ├── README.md └── CHANGELOG.md /.jshintignore: -------------------------------------------------------------------------------- 1 | /client/ 2 | /node_modules/ 3 | -------------------------------------------------------------------------------- /lib/database/datasource.js: -------------------------------------------------------------------------------- 1 | function Datasource() { 2 | /** 3 | * 4 | * @type {Main_Database_Adapter} 5 | */ 6 | this.dataStorage = null; 7 | this.cacheStorage = null; 8 | }; 9 | 10 | /** 11 | * 12 | * @param {Main_Database_Adapter} database 13 | */ 14 | Datasource.prototype.setMainDatabase = function(database) { 15 | this.dataStorage = database; 16 | }; 17 | 18 | Datasource.prototype.setCacheDatabase = function(database) { 19 | this.cacheStorage = database; 20 | }; 21 | 22 | module.exports = Datasource; 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Telepat 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | 30 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | 30 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "esnext": true, 4 | "bitwise": false, 5 | "camelcase": true, 6 | "curly": true, 7 | "eqeqeq": true, 8 | "forin": false, 9 | "eqnull": true, 10 | "immed": true, 11 | "indent": 4, 12 | "latedef": "nofunc", 13 | "newcap": true, 14 | "nonew": true, 15 | "noarg": true, 16 | "quotmark": "single", 17 | "undef": true, 18 | "unused": true, 19 | "trailing": true, 20 | "sub": true, 21 | "maxlen": 120, 22 | "devel": true, 23 | "mocha": true, 24 | "freeze": true, 25 | "funcscope": true, 26 | "globals": { 27 | "app": false, 28 | "it": false, 29 | "describe": false 30 | }, 31 | "shadow": "outer" 32 | } 33 | -------------------------------------------------------------------------------- /lib/systemMessage.js: -------------------------------------------------------------------------------- 1 | var Models = require('telepat-models'); 2 | 3 | var SystemMessageProcessor = { 4 | process: function(message) { 5 | if (message.to == '_all' || message.to == SystemMessageProcessor.identity) { 6 | switch(message.action) { 7 | case 'update_app': { 8 | SystemMessageProcessor.updateApp(message.content.appId, message.content.appObject); 9 | 10 | break; 11 | } 12 | case 'delete_app': { 13 | SystemMessageProcessor.deleteApp(message.content.id); 14 | } 15 | } 16 | } 17 | }, 18 | updateApp: function(appId, app) { 19 | Models.Application.loadedAppModels[appId] = app; 20 | }, 21 | deleteApp: function(appId) { 22 | delete Models.Application.loadedAppModels[appId]; 23 | } 24 | }; 25 | 26 | SystemMessageProcessor.identity = null; 27 | 28 | module.exports = SystemMessageProcessor; 29 | -------------------------------------------------------------------------------- /utils/profiling.js: -------------------------------------------------------------------------------- 1 | var async = require('async'); 2 | 3 | var ProfilingContext = function() { 4 | this.timestamps = []; 5 | this.timerCollection = []; 6 | this.functions = []; 7 | this.initialTimestamp = null; 8 | }; 9 | 10 | ProfilingContext.prototype.initial = function() { 11 | this.initialTimestamp = Math.floor(parseInt(process.hrtime().join(''))/1000); 12 | }; 13 | 14 | ProfilingContext.prototype.addMark = function(name) { 15 | var timestamp = Math.floor(parseInt(process.hrtime().join(''))/1000); 16 | if (!this.timestamps.length) 17 | this.timerCollection.push(timestamp - this.initialTimestamp); 18 | else 19 | this.timerCollection.push(timestamp - this.timestamps[this.timestamps.length-1]); 20 | this.timestamps.push(timestamp); 21 | this.functions.push(name); 22 | }; 23 | 24 | ProfilingContext.prototype.show = function() { 25 | var self = this; 26 | async.reduce(this.timerCollection, 0, function(memo, item, c) { 27 | c(null, memo+item); 28 | }, function(err, totalTime) { 29 | console.log('Total time: '+totalTime+' μs'); 30 | self.functions.forEach(function(item, index) { 31 | console.log('['+item+']: '+self.timerCollection[index]+' μs ('+((self.timerCollection[index]/totalTime*100).toPrecision(3)+'%')+')'); 32 | }); 33 | }); 34 | }; 35 | 36 | module.exports = ProfilingContext; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telepat-models", 3 | "version": "0.4.4", 4 | "description": "Telepat lib used by the API and the workers", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git@github.com:telepat-io/telepat-models.git" 9 | }, 10 | "keywords": [ 11 | "telepat", 12 | "api", 13 | "models" 14 | ], 15 | "bugs": { 16 | "url": "https://github.com/telepat-io/telepat-models/issues" 17 | }, 18 | "author": { 19 | "name": "Răzvan Botea", 20 | "email": "razvan@telepat.io" 21 | }, 22 | "contributors": [ 23 | { 24 | "name": "Răzvan Botea", 25 | "email": "razvan@telepat.io" 26 | }, 27 | { 28 | "name": "Gabi Dobocan", 29 | "email": "gabi@telepat.io" 30 | } 31 | ], 32 | "directories": { 33 | "lib": "lib/" 34 | }, 35 | "license": "Apache-2.0", 36 | "dependencies": { 37 | "agentkeepalive": "2.0.3", 38 | "amqplib": "0.4.1", 39 | "async": "1.5.2", 40 | "azure": "0.10.6", 41 | "clone": "1.0.2", 42 | "dateformat": "1.0.12", 43 | "dot-object": "1.5.1", 44 | "elasticsearch": "10.1.3", 45 | "kafka-node": "0.3.2", 46 | "lz4": "0.5.2", 47 | "object-merge": "2.5.1", 48 | "sprintf-js": "1.0.3", 49 | "uuid": "2.0.1", 50 | "validator": "6.2.0", 51 | "winston": "2.1.1" 52 | }, 53 | "optionalDependencies": { 54 | "winston-syslog": "1.2.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | module.exports.Application = require('./lib/Application'); 4 | 5 | module.exports.Context = require('./lib/Context'); 6 | 7 | module.exports.Model = require('./lib/Model'); 8 | 9 | module.exports.Subscription = require('./lib/Subscription'); 10 | 11 | module.exports.Admin = require('./lib/Admin'); 12 | 13 | module.exports.User = require('./lib/User'); 14 | 15 | module.exports.utils = require('./utils/utils'); 16 | module.exports.Builders = require('./utils/filterbuilder'); 17 | 18 | module.exports.Channel = require('./lib/Channel'); 19 | module.exports.Delta = require('./lib/Delta'); 20 | 21 | module.exports.ProfilingContext = require('./utils/profiling'); 22 | 23 | module.exports.TelepatError = require('./lib/TelepatError'); 24 | 25 | module.exports.Datasource = require('./lib/database/datasource'); 26 | module.exports.ElasticSearch = require('./lib/database/elasticsearch_adapter'); 27 | 28 | module.exports.TelepatLogger = require('./lib/logger/logger'); 29 | 30 | module.exports.TelepatIndexedList = require('./lib/TelepatIndexedLists'); 31 | 32 | module.exports.ConfigurationManager = require('./lib/ConfigurationManager'); 33 | 34 | module.exports.SystemMessageProcessor = require('./lib/systemMessage'); 35 | 36 | fs.readdirSync(__dirname+'/lib/message_queue').forEach(function(filename) { 37 | var filenameParts = filename.split('_'); 38 | 39 | if (filenameParts.pop() == 'queue.js') { 40 | module.exports[filenameParts.join('_')] = require('./lib/message_queue/'+filename); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telepat Models Library 2 | 3 | This package is used by the [Telepat API](https://github.com/telepat-io/telepat-api) and the [Telepat Worker](https://github.com/telepat-io/telepat-worker). 4 | 5 | This package contains the libraries for using various telepat resources: 6 | 7 | * Databases: 8 | * At the moment only elasticSearch support is implemented (version 1.7.x). Configuration variables: 9 | * `TP_ES_HOST`: Elasticsearch server:port. This option will use autodiscovery. 10 | * `TP_ES_HOSTS`: server1:port,server2:port. This option will not use autodiscovery. 11 | * `TP_ES_INDEX`: Elasticsearch index 12 | * `TP_ES_SUBSCRIBE_LIMIT` (optional): How many results the modelSearch method (used in subscriptions) should return (paginated). Default 64. 13 | * `TP_ES_GET_LIMIT` (optional): How many resutls every other search methods should return (not paginated, fixed). Default 384. 14 | * The state database doesn't use the adapter model yet because it's locked to Redis. Only `Subscription.js` uses it. 15 | * `TP_REDIS_HOST`: Redis server 16 | * `TP_REDIS_PORT`: Redis server port 17 | 18 | * Messaging Systems: 19 | * Apache Kafka 20 | * `TP_KFK_HOST`: Kafka (zooekeeper) server 21 | * `TP_KFK_PORT`: Kafka (zooekeeper) server port 22 | * Azure ServiceBus 23 | * `TP_AZURESB_CONNECTION_STRING`: Azure SB connection string 24 | * `TP_AZURESB_MSG_POLLING`: How fast should the messaging server be polled (in milliseconds) 25 | * AMQP: we've tested it with RabbitMQ 3.5.5 26 | * `TP_AMQP_HOST`: AMQP server host 27 | * `TP_AMQP_USER`: AMQP user used by Telepat 28 | * `TP_AMQP_PASSWORD`: The password for the user 29 | 30 | * Loggers: 31 | * We use winston logger: [Winston](https://github.com/winstonjs/winston) 32 | * `TP_LOGGER`: the name of the Winston logger (eg.: Console) 33 | * `TP_LOG_LEVEL`: logging level 34 | -------------------------------------------------------------------------------- /lib/message_queue/messaging_client.js: -------------------------------------------------------------------------------- 1 | var MessagingClient = function(config, name, channel) { 2 | this.broadcast = config.broadcast ? true : false; 3 | delete config.broadcast; 4 | 5 | this.config = config || {}; 6 | this.channel = config.exclusive ? name : channel; 7 | this.name = name; 8 | this.connectionClient = null; 9 | this.onReadyFunc = null; 10 | }; 11 | 12 | /** 13 | * @callback onMessageCb 14 | * @param {string} data 15 | */ 16 | /** 17 | * @abstract 18 | * @param {onMessageCb} callback 19 | */ 20 | MessagingClient.prototype.onMessage = function(callback) { 21 | throw new Error('Unimplemented onMessage function.'); 22 | }; 23 | 24 | /** 25 | * @abstract 26 | * @param {onMessageCb} callback 27 | */ 28 | MessagingClient.prototype.onSystemMessage = function(callback) { 29 | throw new Error('Unimplemented onSystemMessage function.'); 30 | }; 31 | 32 | MessagingClient.prototype.onReady = function(callback) { 33 | this.onReadyFunc = callback; 34 | }; 35 | 36 | /** 37 | * @abstract 38 | * @param {string[]} messages 39 | * @param {string} channel 40 | * @param callback 41 | */ 42 | MessagingClient.prototype.send = function(message, channel, callback) { 43 | throw new Error('Unimplemented send function.'); 44 | }; 45 | 46 | /** 47 | * @abstract 48 | * @param message 49 | * @param callback 50 | */ 51 | MessagingClient.prototype.sendSystemMessages = function(message, callback) { 52 | throw new Error('Unimplemented sendSystemMessages function.'); 53 | }; 54 | 55 | /** 56 | * @abstract 57 | * @param {string[]} messages 58 | * @param {string} channel 59 | * @param callback 60 | */ 61 | MessagingClient.prototype.publish = function(message, channel, callback) { 62 | throw new Error('Unimplemented publish function.'); 63 | }; 64 | 65 | MessagingClient.prototype.shutdown = function(callback) { 66 | throw new Error('Unimplemented shutdown function.'); 67 | }; 68 | 69 | MessagingClient.prototype.on = function(event, callback) { 70 | if (typeof this.connectionClient.on == 'function') 71 | this.connectionClient.on(event, callback); 72 | }; 73 | 74 | module.exports = MessagingClient; 75 | -------------------------------------------------------------------------------- /lib/logger/logger.js: -------------------------------------------------------------------------------- 1 | var winston = require('winston'); 2 | var dateformat = require('dateformat'); 3 | 4 | var TelepatLogger = function(options) { 5 | this.options = options; 6 | 7 | TelepatLogger.loggers = { 8 | Syslog: 'winston-syslog' 9 | }; 10 | 11 | if (options.type != 'Console') { 12 | try { 13 | require(TelepatLogger.loggers[options.type])[options.type]; 14 | winston.add(winston.transports[options.type], options.settings); 15 | winston.remove(winston.transports.Console); 16 | } catch (e) { 17 | console.log('Could not load winston logger: '+e); 18 | } 19 | } else { 20 | winston.colorize = true; 21 | } 22 | 23 | winston.setLevels(winston.config.syslog.levels); 24 | winston.level = options.settings.level || 'info'; 25 | }; 26 | 27 | /** 28 | * 29 | * @param {string} level 30 | * @param {string} message 31 | */ 32 | TelepatLogger.prototype.log = function(level, message) { 33 | var timestamp = dateformat(new Date(), 'yyyy-mm-dd HH:MM:ss.l'); 34 | 35 | message = '['+timestamp+']['+this.options.name+'] '+message; 36 | winston.log(level, message); 37 | }; 38 | 39 | /** 40 | * 41 | * @param {string} message 42 | */ 43 | TelepatLogger.prototype.debug = function(message) { 44 | this.log('debug', message); 45 | }; 46 | 47 | /** 48 | * 49 | * @param {string} message 50 | */ 51 | TelepatLogger.prototype.info = function(message) { 52 | this.log('info', message); 53 | }; 54 | 55 | /** 56 | * 57 | * @param {string} message 58 | */ 59 | TelepatLogger.prototype.notice = function(message) { 60 | this.log('notice', message); 61 | }; 62 | 63 | /** 64 | * 65 | * @param {string} message 66 | */ 67 | TelepatLogger.prototype.warning = function(message) { 68 | this.log('warn', message); 69 | }; 70 | 71 | /** 72 | * 73 | * @param {string} message 74 | */ 75 | TelepatLogger.prototype.error = function(message) { 76 | this.log('error', message); 77 | }; 78 | 79 | /** 80 | * 81 | * @param {string} message 82 | */ 83 | TelepatLogger.prototype.critical = function(message) { 84 | this.log('crit', message); 85 | }; 86 | 87 | /** 88 | * 89 | * @param {string} message 90 | */ 91 | TelepatLogger.prototype.alert = function(message) { 92 | this.log('alert', message); 93 | }; 94 | 95 | /** 96 | * 97 | * @param {string} message 98 | */ 99 | TelepatLogger.prototype.emergency = function(message) { 100 | this.log('emerg', message); 101 | }; 102 | 103 | module.exports = TelepatLogger; 104 | -------------------------------------------------------------------------------- /lib/message_queue/kafka_queue.js: -------------------------------------------------------------------------------- 1 | var MessagingClient = require('./messaging_client'); 2 | var Application = require('../Application'); 3 | var kafka = require('kafka-node'); 4 | var async = require('async'); 5 | 6 | var KafkaClient = function(config, name, channel){ 7 | MessagingClient.call(this, config, name, channel); 8 | 9 | this.config = config; 10 | var self = this; 11 | 12 | async.series([ 13 | function(callback) { 14 | self.connectionClient = kafka.Client(self.config.host+':'+self.config.port+'/', self.name); 15 | self.connectionClient.on('ready', function() { 16 | Application.logger.info('Client connected to Zookeeper & Kafka Messaging Client.'); 17 | callback(); 18 | }); 19 | self.connectionClient.on('error', function() { 20 | Application.logger.error('Kafka broker not available. Trying to reconnect.'); 21 | }); 22 | }, 23 | function(callback) { 24 | var groupId = self.broadcast ? self.name : channel; 25 | if (channel) { 26 | self.kafkaConsumer = new kafka.HighLevelConsumer(self.connectionClient, [{topic: channel}], {groupId: groupId}); 27 | self.kafkaConsumer.on('error', function(err) { 28 | console.log(err); 29 | }); 30 | } 31 | callback(); 32 | }, 33 | function(callback) { 34 | self.kafkaProducer = new kafka.HighLevelProducer(self.connectionClient); 35 | self.kafkaProducer.on('error', function() {}); 36 | callback(); 37 | } 38 | ], function(err) { 39 | if (err) { 40 | Application.logger.emergency('Kafka Queue: '+err.toString()); 41 | process.exit(-1); 42 | } else { 43 | if(typeof self.onReadyFunc == 'function') { 44 | self.onReadyFunc(); 45 | } 46 | } 47 | }); 48 | }; 49 | 50 | KafkaClient.prototype = Object.create(MessagingClient.prototype); 51 | 52 | KafkaClient.prototype.send = function(messages, channel, callback) { 53 | this.kafkaProducer.send([{ 54 | topic: channel, 55 | messages: messages 56 | }], function(err) { 57 | callback(err); 58 | }); 59 | }; 60 | 61 | KafkaClient.prototype.publish = KafkaClient.prototype.send; 62 | 63 | KafkaClient.prototype.onMessage = function(callback) { 64 | if (this.kafkaConsumer) { 65 | this.kafkaConsumer.on('message', function(message) { 66 | callback(message.value); 67 | }); 68 | } 69 | }; 70 | 71 | KafkaClient.prototype.shutdown = function(callback) { 72 | this.connectionClient.close(callback); 73 | }; 74 | 75 | KafkaClient.prototype.consumerOn = function(event, callback) { 76 | if (this.kafkaConsumer) 77 | this.kafkaConsumer.on(event, callback); 78 | }; 79 | 80 | KafkaClient.prototype.producerOn = function(event, callback) { 81 | this.kafkaProducer.on(event, callback); 82 | }; 83 | 84 | module.exports = KafkaClient; 85 | -------------------------------------------------------------------------------- /lib/Admin.js: -------------------------------------------------------------------------------- 1 | var Application = require('./Application'); 2 | var User = require('./User'); 3 | var async = require('async'); 4 | var FilterBuilder = require('../utils/filterbuilder').FilterBuilder; 5 | var guid = require('uuid'); 6 | var TelepatError = require('./TelepatError'); 7 | 8 | /** 9 | * @callback adminCb 10 | * @param {TelepatError|Error|null} err 11 | * @param {Object] admin 12 | */ 13 | /** 14 | * Retrieves an admin by email address or id. 15 | * @param {Object} admin 16 | * @param {Object} [admin.id] 17 | * @param {Object} [admin.email] 18 | * @param {adminCb} callback 19 | * @constructor 20 | */ 21 | function Admin(admin, callback) { 22 | if (admin.id) 23 | Application.datasource.dataStorage.getObjects([admin.id], function(errs, results) { 24 | if (errs.length) 25 | return callback(errs[0]); 26 | callback(null, results[0]); 27 | }); 28 | else if (admin.email) { 29 | var filter = new FilterBuilder(); 30 | filter.addFilter('is', {email: admin.email}); 31 | 32 | Application.datasource.dataStorage.searchObjects({modelName: 'admin', filters: filter}, function(err, results) { 33 | if (err) 34 | return callback(err); 35 | if (!results.length) 36 | return callback(new TelepatError(TelepatError.errors.AdminNotFound)); 37 | 38 | callback(null, results[0]); 39 | }); 40 | } 41 | } 42 | 43 | /** 44 | * Creates a new admin. 45 | * @param {string} email The email address of the admin 46 | * @param {Object} props Properties of the admin 47 | * @param {adminCb} callback 48 | */ 49 | Admin.create = function(email, props, callback) { 50 | props.email = email; 51 | props.id = guid.v4(); 52 | props.created = Math.floor((new Date()).getTime()/1000); 53 | props.modified = props.created; 54 | props.type = 'admin'; 55 | Application.datasource.dataStorage.createObjects([props], function(err) { 56 | callback(err, props); 57 | }); 58 | } 59 | 60 | /** 61 | * Updates an admin object 62 | * @param {Object[]} patches 63 | * @param {adminCb} callback 64 | */ 65 | Admin.update = function(patches, callback) { 66 | Application.datasource.dataStorage.updateObjects(patches, function(errs) { 67 | callback(errs && errs.length ? errs[0] : null); 68 | }); 69 | } 70 | 71 | /** 72 | * 73 | * @param {string} email 74 | * @param {adminCb} callback 75 | */ 76 | Admin.delete = function(admin, callback) { 77 | async.waterfall([ 78 | function get(callback1) { 79 | new Admin(admin, callback1); 80 | }, 81 | function deleteAdmin(admin, callback1) { 82 | var adminToDelete = {}; 83 | adminToDelete[admin.id] = 'admin'; 84 | Application.datasource.dataStorage.deleteObjects(adminToDelete, function(errs) { 85 | callback1(errs && errs.length ? errs[0] : null); 86 | }); 87 | } 88 | ], callback); 89 | } 90 | 91 | module.exports = Admin; 92 | -------------------------------------------------------------------------------- /lib/database/main_database_adapter.js: -------------------------------------------------------------------------------- 1 | var Main_Database_Adapter = function(connection) { 2 | this.connection = connection; 3 | /** 4 | * 5 | * @type {Function|null} 6 | */ 7 | this.onReadyCallback = null; 8 | }; 9 | 10 | /** 11 | * 12 | * @param {Function} callback Called after the database has finished setting up 13 | */ 14 | Main_Database_Adapter.prototype.onReady = function(callback) { 15 | this.onReadyCallback = callback; 16 | }; 17 | 18 | /** 19 | * @callback returnObjectsCb 20 | * @param {TelepatError[]} err 21 | * @param {Object[]} results 22 | */ 23 | /** 24 | * @abstract 25 | * @param {String[]} ids 26 | * @param {returnObjectsCb} callback 27 | */ 28 | Main_Database_Adapter.prototype.getObjects = function(ids, callback) { 29 | throw new Error('Database adapter "getObjects" not implemented'); 30 | }; 31 | 32 | /** 33 | * @callback ScanCallback 34 | * @param {Object[]} results 35 | */ 36 | /** 37 | * @abstract 38 | * @param {Object} options 39 | * @param {string} {options.modelName} 40 | * @param {FilterBuilder} [options.filters] 41 | * @param {Object} [options.sort] 42 | * @param {Number} [options.offset] 43 | * @param {Number} [options.limit] 44 | * @param {string[]} [options.fields] 45 | * @param {ScanCallback} [options.scanFunction] 46 | * @param {returnObjectsCb} callback 47 | */ 48 | Main_Database_Adapter.prototype.searchObjects = function(options, callback) { 49 | throw new Error('Database adapter "searchObjects" not implemented'); 50 | }; 51 | 52 | /** 53 | * @callback countObjectsCB 54 | * @param {Object} err 55 | * @param {Object} result 56 | * @param {Number} result.count 57 | * @param {Number} [result.aggregation] 58 | */ 59 | /** 60 | * 61 | * @abstract 62 | * @param {Object} options 63 | * @param {string} options.modelName 64 | * @param {FilterBuilder} [options.filters] 65 | * @param {Object} [options.aggregation] 66 | * @param {countObjectsCB} callback 67 | */ 68 | Main_Database_Adapter.prototype.countObjects = function(options, callback) { 69 | throw new Error('Database adapter "countObjects" not implemented'); 70 | }; 71 | 72 | /** 73 | * @callback CUDObjectsCb 74 | * @param {TelepatError[]|null} err 75 | */ 76 | /** 77 | * @abstract 78 | * @param {Object[]} objects 79 | * @param {CUDObjectsCb} callback 80 | */ 81 | Main_Database_Adapter.prototype.createObjects = function(objects, callback) { 82 | throw new Error('Database adapter "createObjects" not implemented'); 83 | }; 84 | 85 | /** 86 | * @abstract 87 | * @param {Object[]} patches 88 | * @param {CUDObjectsCb} callback 89 | */ 90 | Main_Database_Adapter.prototype.updateObjects = function(patches, callback) { 91 | throw new Error('Database adapter "updateObjects" not implemented'); 92 | }; 93 | 94 | /** 95 | * @abstract 96 | * @param {Object[]} ids {ID => modelName} 97 | * @param {CUDObjectsCb} callback 98 | */ 99 | Main_Database_Adapter.prototype.deleteObjects = function(ids, callback) { 100 | throw new Error('Database adapter "deleteObjects" not implemented'); 101 | }; 102 | 103 | module.exports = Main_Database_Adapter; 104 | -------------------------------------------------------------------------------- /lib/TelepatIndexedLists.js: -------------------------------------------------------------------------------- 1 | var Application = require('./Application'); 2 | var TelepatError = require('./TelepatError'); 3 | var utils = require('../utils/utils'); 4 | var async = require('async'); 5 | 6 | var TelepatIndexedList = { 7 | /** 8 | * @callback appendCb 9 | * @param {Error|null} [err] 10 | */ 11 | 12 | /** 13 | * @callback removeCb 14 | * @param {Error|null} [err] 15 | * @param {Boolean} [removed] 16 | */ 17 | 18 | /** 19 | * @callback getCb 20 | * @param {Error|null} [err] 21 | * @param {Object[]} [results] 22 | */ 23 | 24 | /** 25 | * 26 | * @param {string} listName Name of the list 27 | * @param {string} indexedProperty The property that's being indexed by 28 | * @param {Object} object The key of this object is the member that will be inserted with its value 29 | * @param {appendCb} callback 30 | */ 31 | append: function(listName, indexedProperty, object , callback) { 32 | var commandArgs = ['blg:til:' + listName + ':' + indexedProperty + ':' + object[indexedProperty]]; 33 | 34 | for(var prop in object) { 35 | if (typeof object[prop] != 'object') { 36 | commandArgs.push(prop, object[prop]); 37 | } 38 | } 39 | 40 | Application.redisCacheClient.hmset(commandArgs, function(err) { 41 | if (err) return callback(err); 42 | else callback(); 43 | }); 44 | }, 45 | 46 | /** 47 | * 48 | * @param {string} listName Name of the list 49 | * @param {string} indexedProperty The property that's being indexed by 50 | * @param {string[]} members Array of memembers to check for 51 | * @param {getCb} callback 52 | */ 53 | get: function(listName, indexedProperty, members, callback) { 54 | var baseKey = 'blg:til:'+listName+':'+indexedProperty; 55 | var tranzaction = Application.redisCacheClient.multi(); 56 | 57 | members.forEach(function(member) { 58 | tranzaction.hgetall([baseKey + ':' + member]); 59 | }); 60 | 61 | tranzaction.exec(function(err, replies) { 62 | if (err) return callback(err); 63 | var memebershipResults = {}; 64 | 65 | async.forEachOf(replies, function(result, index, c) { 66 | memebershipResults[members[index]] = result || false; 67 | c(); 68 | }, function() { 69 | callback(null, memebershipResults); 70 | }); 71 | }); 72 | }, 73 | 74 | /** 75 | * 76 | * @param {string} listName Name of the list to remove 77 | * @param {removeCb} callback 78 | */ 79 | removeList: function(listName, callback) { 80 | var keyPattern = 'blg:til:'+listName+':*'; 81 | var self = this; 82 | 83 | utils.scanRedisKeysPattern(keyPattern, Application.redisCacheClient, function(err, results) { 84 | if (err) return callback(err); 85 | 86 | if (!results.length) 87 | return callback(new TelepatError(TelepatError.errors.TilNotFound, [listName])); 88 | 89 | Application.redisCacheClient.del(results, function(err, removed) { 90 | if (err) return callback(err); 91 | 92 | callback(null, new Boolean(removed)); 93 | }); 94 | }) 95 | }, 96 | 97 | /** 98 | * 99 | * @param {string} listName Name of the list 100 | * @param {string} indexedProperty The property that's being indexed by 101 | * @param {string[]} members Array of memembers to remove 102 | * @param {removeCb} callback 103 | */ 104 | removeMember: function(listName, indexedProperty, members, callback) { 105 | var baseKey = 'blg:til:'+listName+':'+indexedProperty; 106 | var existingMembers = {}; //to avoid duplicate members 107 | var delArguments = []; 108 | 109 | members.forEach(function(member) { 110 | if (!existingMembers[member]) { 111 | delArguments.push([baseKey + ':' + member]); 112 | existingMembers[member] = true; 113 | } 114 | }); 115 | 116 | Application.redisCacheClient.del(delArguments, function(err, reply) { 117 | callback(err, reply); 118 | }); 119 | } 120 | }; 121 | 122 | module.exports = TelepatIndexedList; 123 | -------------------------------------------------------------------------------- /lib/Context.js: -------------------------------------------------------------------------------- 1 | var Application = require('./Application'); 2 | var async = require('async'); 3 | var FilterBuilder = require('../utils/filterbuilder').FilterBuilder; 4 | var guid = require('uuid'); 5 | 6 | /** 7 | * Gets a context object. 8 | * @param _id number Context ID 9 | * @param callback 10 | * @constructor 11 | */ 12 | function Context(_id, callback) { 13 | Application.datasource.dataStorage.getObjects([_id], function(errs, results) { 14 | if (errs.length) return callback(errs[0]); 15 | callback(null, results[0]); 16 | }); 17 | } 18 | 19 | /** 20 | * Loads the configuration spec file. Automatically called at module require. 21 | */ 22 | Context.load = function() { 23 | Context._model = require('../models/context.json'); 24 | 25 | if (!Context._model) { 26 | Application.logger.emergency('Model \'context\' spec file does not exist.'); 27 | process.exit(-1); 28 | } 29 | }; 30 | 31 | /** 32 | * Get all contexts. 33 | * @param [by_app] integer Gets the context only from this application. 34 | * @param callback 35 | */ 36 | Context.getAll = function(by_app, offset, limit, callback) { 37 | var filter = null; 38 | offset = offset || 0; 39 | limit = limit || Application.datasource.dataStorage.config.get_limit; 40 | 41 | if (by_app) 42 | filter = (new FilterBuilder('and')).addFilter('is', {application_id: by_app}); 43 | 44 | Application.datasource.dataStorage.searchObjects({modelName: 'context', filters: filter, offset: offset, limit: limit}, callback); 45 | } 46 | 47 | /** 48 | * Gets the curent index of the contexts. 49 | * @param callback 50 | */ 51 | Context.count = function(callback) { 52 | Application.datasource.dataStorage.countObjects({modelName: 'context'}, callback); 53 | } 54 | 55 | 56 | /** 57 | * Creates a new context 58 | * @param props Object properties of the context 59 | * @param callback 60 | */ 61 | Context.create = function(props, callback) { 62 | props.type = 'context'; 63 | props.id = guid.v4(); 64 | props.created = Math.floor((new Date()).getTime()/1000); 65 | props.modified = props.created; 66 | 67 | Application.datasource.dataStorage.createObjects([props], function(err) { 68 | if (err) return callback(err); 69 | callback(null, props); 70 | }); 71 | } 72 | 73 | /** 74 | * Updates a context. 75 | * @param id integer Context ID 76 | * @param patches[] Object The new properties of the context 77 | * @param callback 78 | */ 79 | Context.update = function(patches, callback) { 80 | Application.datasource.dataStorage.updateObjects(patches, function(errs) { 81 | callback(errs.length ? errs[0] : null); 82 | }); 83 | } 84 | 85 | /** 86 | * Deletes a context and all of its objects and subscriptions 87 | * @param id integer Context ID 88 | * @param callback 89 | */ 90 | Context.delete = function(id, callback) { 91 | var delObj = {}; 92 | delObj[id] = 'context'; 93 | 94 | async.series([ 95 | function(callback1) { 96 | Application.datasource.dataStorage.deleteObjects(delObj, function(errs) { 97 | if (errs) return callback1(errs[0]); 98 | callback1(); 99 | }); 100 | }, 101 | function(callback1) { 102 | var deleteContextObjects = function(obj) { 103 | var deleteObjects = {}; 104 | async.each(obj, function(o, c) { 105 | deleteObjects[o.id] = o.type; 106 | c(); 107 | }, function() { 108 | Application.datasource.dataStorage.deleteObjects(deleteObjects, function(errs) { 109 | if (errs && errs.length > 1) { 110 | Application.logger.warning('Failed to delete '+errs.length+' context objects.'); 111 | } 112 | }); 113 | }); 114 | }; 115 | var filter = (new FilterBuilder()).addFilter('is', {context_id: id}); 116 | Application.datasource.dataStorage.searchObjects({filters: filter, fields: ['id', 'type'], scanFunction: deleteContextObjects}, callback1); 117 | } 118 | ], callback); 119 | } 120 | 121 | module.exports = Context; 122 | -------------------------------------------------------------------------------- /utils/filterbuilder.js: -------------------------------------------------------------------------------- 1 | var TelepatError = require('../lib/TelepatError'); 2 | 3 | var BuilderNode = function(name) { 4 | if (BuilderNode.CONNECTORS.indexOf(name) === -1) 5 | throw new TelepatError(TelepatError.errors.QueryError, ['unsupported query connector "'+name+'"']); 6 | 7 | this.parent = null; 8 | /** 9 | * 10 | * @type {BuilderNode[]|Object[]} 11 | */ 12 | this.children = []; 13 | this.name = name; 14 | }; 15 | 16 | BuilderNode.CONNECTORS = [ 17 | 'and', 18 | 'or' 19 | ]; 20 | 21 | BuilderNode.FILTERS = [ 22 | 'is', 23 | 'not', 24 | 'exists', 25 | 'range', 26 | 'in_array', 27 | 'like' 28 | ]; 29 | 30 | BuilderNode.prototype.addFilter = function(name, value) { 31 | if (BuilderNode.FILTERS.indexOf(name) !== -1) { 32 | var filter = {}; 33 | filter[name] = value; 34 | this.children.push(filter); 35 | } else 36 | throw new TelepatError(TelepatError.errors.QueryError, ['invalid filter "'+name+'"']); 37 | }; 38 | 39 | /** 40 | * 41 | * @param {BuilderNode} node 42 | */ 43 | BuilderNode.prototype.addNode = function(node) { 44 | node.parent = this; 45 | this.children.push(node); 46 | }; 47 | 48 | /** 49 | * 50 | * @param {BuilderNode} node 51 | */ 52 | BuilderNode.prototype.removeNode = function(node) { 53 | var idx = this.children.indexOf(node); 54 | 55 | if (idx !== -1) { 56 | node.parent = null; 57 | return this.children.splice(idx, 1)[0]; 58 | } else { 59 | return null; 60 | } 61 | }; 62 | 63 | BuilderNode.prototype.toObject = function() { 64 | var obj = {}; 65 | obj[this.name] = []; 66 | 67 | this.children.forEach(function(item) { 68 | if (item instanceof BuilderNode) 69 | obj[this.name].push(item.toObject()); 70 | else 71 | obj[this.name].push(item); 72 | }, this); 73 | 74 | return obj; 75 | }; 76 | 77 | var FilterBuilder = function(initial) { 78 | /** 79 | * 80 | * @type {null|BuilderNode} 81 | */ 82 | this.root = null; 83 | 84 | if (initial) 85 | this.root = new BuilderNode(initial); 86 | else 87 | this.root = new BuilderNode('and'); 88 | 89 | this.pointer = this.root; 90 | }; 91 | 92 | FilterBuilder.prototype.and = function() { 93 | if (this.root === null) { 94 | this.root = new BuilderNode('and'); 95 | } else { 96 | var child = new BuilderNode('and'); 97 | this.pointer.addNode(child); 98 | this.pointer = child; 99 | } 100 | 101 | return this; 102 | }; 103 | 104 | FilterBuilder.prototype.or = function() { 105 | if (this.root === null) { 106 | this.root = new BuilderNode('or'); 107 | } else { 108 | var child = new BuilderNode('or'); 109 | this.pointer.addNode(child); 110 | this.pointer = child; 111 | } 112 | 113 | return this; 114 | }; 115 | 116 | FilterBuilder.prototype.addFilter = function(name, value) { 117 | this.pointer.addFilter(name, value); 118 | 119 | return this; 120 | }; 121 | 122 | FilterBuilder.prototype.removeNode = function() { 123 | if (this.root !== this.pointer) { 124 | var nodeToRemove = this.pointer; 125 | this.pointer = this.pointer.parent; 126 | 127 | return this.pointer.removeNode(nodeToRemove); 128 | } else 129 | return null; 130 | }; 131 | 132 | FilterBuilder.prototype.isEmpty = function() { 133 | return this.root.children.length ? false : true; 134 | }; 135 | 136 | FilterBuilder.prototype.end = function() { 137 | if (this.pointer.parent) 138 | this.pointer = this.pointer.parent; 139 | 140 | return this; 141 | }; 142 | 143 | FilterBuilder.prototype.build = function() { 144 | return this.root ? this.root.toObject() : null; 145 | }; 146 | 147 | /*var FB = new FilterBuilder('and'); 148 | FB. 149 | or(). 150 | addFilter('is', {a: 1}). 151 | addFilter('is', {b: 2}). 152 | addFilter('is', {c: 3}). 153 | end(). 154 | or(). 155 | addFilter('is', {d: 4}). 156 | addFilter('is', {e: 5}). 157 | addFilter('is', {f: 6});*/ 158 | 159 | module.exports = { 160 | FilterBuilder: FilterBuilder, 161 | BuilderNode: BuilderNode 162 | }; 163 | -------------------------------------------------------------------------------- /lib/message_queue/azure_servicebus_queue.js: -------------------------------------------------------------------------------- 1 | var MessagingClient = require('./messaging_client'); 2 | var Application = require('../Application'); 3 | var azure = require('azure'); 4 | var async = require('async'); 5 | 6 | var AzureServiceBus = function(config, name, channel){ 7 | MessagingClient.call(this, config, name, channel); 8 | 9 | var self = this; 10 | 11 | var envVariables = { 12 | TP_AZURESB_CONNECTION_STRING: process.env.TP_AZURESB_CONNECTION_STRING 13 | }; 14 | 15 | if (channel) { 16 | envVariables.TP_AZURESB_MSG_POLLING = process.env.TP_AZURESB_MSG_POLLING; 17 | } 18 | 19 | var validEnvVariables = true; 20 | 21 | for(var varName in envVariables) { 22 | if (envVariables[varName] === undefined) { 23 | Application.logger.notice('Missing environment variable "'+varName+'". Trying configuration file.'); 24 | 25 | if (!this.config || !Object.getOwnPropertyNames(this.config).length) { 26 | Application.logger.emergency('Configuration file is missing configuration for Azure ServiceBus ' + 27 | 'messaging client.'); 28 | process.exit(-1); 29 | } 30 | 31 | validEnvVariables = false; 32 | break; 33 | } 34 | } 35 | 36 | if (validEnvVariables) { 37 | this.config.connection_string = process.env.TP_AZURESB_CONNECTION_STRING; 38 | this.config.polling_interval =process.env.TP_AZURESB_MSG_POLLING; 39 | } 40 | 41 | /** 42 | * 43 | * @type {ServiceBusService|*} 44 | */ 45 | this.connectionClient = azure.createServiceBusService(config.connection_string); 46 | this.isSubscribed = false; 47 | //this can be undefined if receiving messages from topics is not desired 48 | this.subscription = channel; 49 | 50 | async.series([ 51 | function Connect(callback) { 52 | if (channel) { 53 | self.connectionClient.getSubscription(channel, name, function(err) { 54 | if (err && err.statusCode == 404) { 55 | self.connectionClient.createSubscription(channel, name, function(err) { 56 | if (err) return callback(err); 57 | self.isSubscribed = true; 58 | callback(); 59 | }); 60 | } else if (err) { 61 | console.log(err); 62 | Application.logger.error('Failed connecting to Azure ServiceBus ('+ 63 | err.toString()+'). Reconnecting... '); 64 | setTimeout(function() { 65 | Connect(callback); 66 | }, 1000); 67 | } else { 68 | self.isSubscribed = true; 69 | callback() 70 | } 71 | }); 72 | } else 73 | callback(); 74 | } 75 | ], function(err) { 76 | if (err) { 77 | console.log(err); 78 | console.log('Aborting...'.red); 79 | process.exit(-1); 80 | } else { 81 | if(typeof self.onReadyFunc == 'function') { 82 | Application.logger.info('Connected to Azure ServiceBus Messaging Queue'); 83 | self.onReadyFunc(); 84 | } 85 | } 86 | }); 87 | }; 88 | 89 | AzureServiceBus.prototype = Object.create(MessagingClient.prototype); 90 | 91 | AzureServiceBus.prototype.send = function(messages, channel, callback) { 92 | var self = this; 93 | async.each(messages, function(message, c) { 94 | self.connectionClient.sendTopicMessage(channel, message, c); 95 | }, callback); 96 | }; 97 | 98 | AzureServiceBus.prototype.publish = AzureServiceBus.prototype.send; 99 | 100 | AzureServiceBus.prototype.onMessage = function(callback) { 101 | var self = this; 102 | 103 | if (this.isSubscribed) { 104 | setInterval(function() { 105 | self.connectionClient.receiveSubscriptionMessage(self.subscription, self.name, {timeoutIntervalInS: 30}, function(err, serverMessage) { 106 | if (err && err == 'No messages to receive') { 107 | return ; 108 | } else if (err) { 109 | Application.logger.error('Error receiving Subscription Message from Azure ServiceBus ('+err.toString()+')'); 110 | } else { 111 | callback(serverMessage.body); 112 | } 113 | }); 114 | }, this.config.polling_interval < 50 ? 50 : this.config.polling_interval); 115 | } 116 | }; 117 | 118 | AzureServiceBus.prototype.shutdown = function(callback) { 119 | callback(); 120 | }; 121 | 122 | module.exports = AzureServiceBus; 123 | -------------------------------------------------------------------------------- /lib/Delta.js: -------------------------------------------------------------------------------- 1 | var guid = require('uuid'); 2 | var Application = require('./Application'); 3 | var cloneObject = require('clone'); 4 | 5 | /** 6 | * @typedef {{ 7 | * op: Delta.PATCH_OP, 8 | * path: string, 9 | * value: any, 10 | * [timestamp]: Number 11 | * }} Patch 12 | */ 13 | 14 | /** 15 | * 16 | * @param {Object} fields 17 | * @param {Delta.OP} fields.op 18 | * @param {Object} fields.object 19 | * @param {Patch[]} fields.patch 20 | * @param {string} fields.application_id 21 | * @param {string} fields.timestamp 22 | * @param {string} [fields.instant] 23 | * @param {Channel[]} subscriptions 24 | * @constructor 25 | */ 26 | var Delta = function(fields, subscriptions) { 27 | this.op = fields.op; 28 | this.object = fields.object; 29 | this.patch = fields.patch; 30 | this.application_id = fields.application_id; 31 | this.timestamp = fields.timestamp; 32 | if (this.instant) 33 | this.instant = fields.instant; 34 | this.subscriptions = subscriptions || []; 35 | }; 36 | 37 | /** 38 | * @enum {string} 39 | * @type {{ADD: string, UPDATE: string, DELETE: string}} 40 | */ 41 | Delta.OP = { 42 | ADD: 'add', 43 | UPDATE: 'update', 44 | DELETE: 'delete' 45 | }; 46 | 47 | /** 48 | * @enum {string} 49 | * @type {{APPEND: string, INCREMENT: string, REPLACE: string, REMOVE: string}} 50 | */ 51 | Delta.PATCH_OP = { 52 | APPEND: 'append', 53 | INCREMENT: 'increment', 54 | REPLACE: 'replace', 55 | REMOVE: 'remove' 56 | }; 57 | 58 | 59 | /*Delta.prototype.clone = function() { 60 | var d = new Delta(this.op, this.value, this.path, this.channel, this.guid, this.ts); 61 | d.subscription = this.subscription; 62 | d.application_id = this.application_id; 63 | 64 | if (this.context) 65 | d.context = this.context; 66 | 67 | if (this.username) 68 | d.username = this.username; 69 | 70 | if (this.instant) 71 | d.instant = this.instant; 72 | 73 | return d; 74 | };*/ 75 | 76 | Delta.prototype.toObject = function() { 77 | var obj = { 78 | op: this.op, 79 | object: this.object, 80 | subscriptions: this.subscriptions, 81 | application_id: this.application_id, 82 | timestamp: this.timestamp 83 | }; 84 | 85 | if (this.op == 'update') 86 | obj.patch = this.patch; 87 | 88 | if (this.instant) 89 | obj.instant = true; 90 | 91 | return obj; 92 | }; 93 | 94 | Delta.formPatch = function(object, op, property) { 95 | var patch = {}; 96 | 97 | if (op) 98 | patch.op = op; 99 | 100 | if (property) { 101 | var prop = Object.keys(property)[0]; 102 | patch.path = object.type+'/'+object.id+'/'+prop; 103 | patch.value = property[prop]; 104 | } else if (object.id) { 105 | patch.path = object.type+'/'+object.id; 106 | } 107 | 108 | return patch; 109 | }; 110 | 111 | Delta.processObject = function(patches, object) { 112 | for (var i in patches) { 113 | var objectField = patches[i].path.split('/')[2]; 114 | 115 | if (patches.hasOwnProperty(i) && ['id', 'type', 'created', 'modified', 'application_id', 'context_id'].indexOf(objectField) == -1) { 116 | switch (patches[i].op) { 117 | case 'replace': { 118 | object[objectField] = patches[i].value; 119 | 120 | break; 121 | } 122 | 123 | case 'increment': { 124 | object[objectField] += patches[i].value; 125 | 126 | break; 127 | } 128 | 129 | case 'append': { 130 | if (Array.isArray(object[objectField])) { 131 | object[objectField].push(patches[i].value); 132 | } else if (typeof object[objectField] == 'string') { 133 | object[objectField] += patches[i].value; 134 | } else if (object[objectField] === undefined) { 135 | object[objectField] = [patches[i].value]; 136 | } 137 | 138 | break; 139 | } 140 | 141 | case 'remove': { 142 | if (Array.isArray(object[objectField])) { 143 | var idx = object[objectField].indexOf(patches[i].value); 144 | if (idx !== -1) 145 | object[objectField].splice(idx, 1); 146 | } 147 | 148 | break; 149 | } 150 | } 151 | } 152 | } 153 | 154 | object.modified = Math.floor((new Date()).getTime()/1000); 155 | 156 | return object; 157 | }; 158 | 159 | module.exports = Delta; 160 | -------------------------------------------------------------------------------- /lib/message_queue/amqp_queue.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @type {MessagingClient|exports|module.exports} 4 | */ 5 | var MessagingClient = require('./messaging_client'); 6 | var amqplib = require('amqplib/callback_api'); 7 | var async = require('async'); 8 | var Application = require('../Application'); 9 | var lz4 = require('../../utils/utils').lz4; 10 | 11 | var AMQPClient = function(config, name, channel) { 12 | MessagingClient.call(this, config, name, channel); 13 | 14 | this.config = config; 15 | var self = this; 16 | this.amqpChannel = null; 17 | this.broadcastQueue = null; 18 | this.assertedQueues = {}; 19 | 20 | async.series([ 21 | function TryConnecting(callback) { 22 | amqplib.connect('amqp://'+self.config.user+':'+self.config.password+'@'+self.config.host, function(err, conn) { 23 | if (err) { 24 | Application.logger.error('Failed connecting to AMQP messaging queue ('+ 25 | err.toString()+'). Retrying... '); 26 | setTimeout(function() { 27 | TryConnecting(callback); 28 | }, 1000); 29 | } else { 30 | self.connectionClient = conn; 31 | callback(); 32 | } 33 | }); 34 | }, 35 | function TryChannel(callback) { 36 | self.connectionClient.createChannel(function(err, ch) { 37 | if (err) { 38 | Application.logger.error('Failed creating channel on the AMQP messaging queue ('+ 39 | err.toString()+'). Retrying... '); 40 | setTimeout(function() { 41 | TryChannel(callback); 42 | }, 1000); 43 | } else { 44 | self.amqpChannel = ch; 45 | //create queue or exchange if it doesnt exist; used for consumers 46 | if (self.broadcast) { 47 | self.amqpChannel.assertExchange(self.channel+'-exchange', 'fanout', {}, function(err) { 48 | if (err) return callback(err); 49 | self.amqpChannel.assertQueue(self.name, {durable: false, autoDelete: true}, function(err1, result) { 50 | if (err1) return callback(err1); 51 | 52 | self.broadcastQueue = result.queue; 53 | self.amqpChannel.bindQueue(self.broadcastQueue, self.channel+'-exchange', '', {}, callback); 54 | }); 55 | }); 56 | } else { 57 | //we only need to assert the queue if the sending of messages is needed 58 | if (self.channel) 59 | self.amqpChannel.assertQueue(self.channel, {}, callback); 60 | else 61 | callback(); 62 | } 63 | } 64 | }); 65 | } 66 | ], function(err) { 67 | if (err) { 68 | Application.logger.emergency('AMQP Queue: '+err.toString()); 69 | process.exit(1); 70 | } 71 | self.amqpChannel.bindQueue(self.broadcastQueue || self.channel, 'amq.fanout', '', {}, function(err) { 72 | if (err) { 73 | Application.logger.error('Failed to bind AMQP queue to amq.fanout'); 74 | self.failedBind = true; 75 | } 76 | 77 | if(typeof self.onReadyFunc == 'function') { 78 | Application.logger.info('Connected to AMQP Messaging Queue'); 79 | self.onReadyFunc(); 80 | } 81 | }); 82 | }); 83 | }; 84 | 85 | AMQPClient.prototype = Object.create(MessagingClient.prototype); 86 | 87 | AMQPClient.prototype.onMessage = function(callback) { 88 | var fromWhere = this.broadcast ? this.broadcastQueue : this.channel; 89 | 90 | this.amqpChannel.consume(fromWhere, function(message) { 91 | if (message !== null) { 92 | lz4.decompress(message.content, function(data) { 93 | callback(data.toString()); 94 | }); 95 | } 96 | }, {noAck: true}); 97 | }; 98 | 99 | AMQPClient.prototype.send = function(messages, channel, callback) { 100 | var self = this; 101 | 102 | if (this.assertedQueues[channel]) { 103 | async.each(messages, function(message, c) { 104 | lz4.compress(message, function(compressed) { 105 | self.amqpChannel.sendToQueue(channel, compressed); 106 | c(); 107 | }); 108 | }, callback); 109 | } else { 110 | this.amqpChannel.checkQueue(channel, function(err) { 111 | if (err) return callback(err); 112 | 113 | self.assertedQueues[channel] = true; 114 | 115 | async.each(messages, function(message, c) { 116 | lz4.compress(message, function(compressed) { 117 | self.amqpChannel.sendToQueue(channel, compressed); 118 | c(); 119 | }); 120 | }, callback); 121 | }); 122 | } 123 | }; 124 | 125 | AMQPClient.prototype.sendSystemMessages = function(to, action, messages, callback) { 126 | if (this.failedBind) 127 | return callback(); 128 | 129 | var self = this; 130 | 131 | async.each(messages, function(message, c) { 132 | var messagePayload = { 133 | _systemMessage: true, 134 | to: to, 135 | action: action, 136 | content: message 137 | }; 138 | 139 | lz4.compress(JSON.stringify(messagePayload), function(compressed) { 140 | self.amqpChannel.publish('amq.fanout', '', compressed); 141 | c(); 142 | }); 143 | }, callback); 144 | }; 145 | 146 | AMQPClient.prototype.publish = function(messages, channel, callback) { 147 | var self = this; 148 | 149 | if (this.assertedQueues[channel+'-exchange']) { 150 | async.each(messages, function(message, c) { 151 | lz4.compress(message, function(compressed) { 152 | self.amqpChannel.publish(channel+'-exchange', '', compressed); 153 | c(); 154 | }); 155 | }, callback); 156 | } else { 157 | this.amqpChannel.assertExchange(channel+'-exchange', 'fanout', {}, function(err) { 158 | if (err) return callback(err); 159 | 160 | self.assertedQueues[channel+'-exchange'] = true; 161 | 162 | async.each(messages, function(message, c) { 163 | lz4.compress(message, function(compressed) { 164 | self.amqpChannel.publish(channel+'-exchange', '', compressed); 165 | c(); 166 | }); 167 | }, callback); 168 | }); 169 | } 170 | }; 171 | 172 | AMQPClient.prototype.shutdown = function(callback) { 173 | this.amqpChannel.close(callback); 174 | }; 175 | 176 | module.exports = AMQPClient; 177 | 34 178 | -------------------------------------------------------------------------------- /lib/Channel.js: -------------------------------------------------------------------------------- 1 | var utils = require('../utils/utils'); 2 | var Application = require('./Application'); 3 | var objectClone = require('clone'); 4 | 5 | var Channel = function Channel(appId, props) { 6 | this.props = {}; 7 | this.filter = null; 8 | this.mask = 0; 9 | this.forceInvalid = false; 10 | this.errorMessage = ''; 11 | 12 | if (!appId) 13 | throw new Error('Must supply application ID to channel constructor'); 14 | else 15 | this.appId = appId; 16 | 17 | if (props) 18 | this.props = props; 19 | } 20 | 21 | Channel.keyPrefix = "blg"; 22 | 23 | Channel.MASKS = { 24 | CONTEXT: 1, 25 | USER: 2, 26 | MODEL: 4, 27 | PARENT: 8, 28 | ID: 16, 29 | }; 30 | 31 | Channel.validChannels = { 32 | 4: Channel.keyPrefix+":{appId}:{model}", //channel used for built-in models (users, contexts) 33 | 5: Channel.keyPrefix+":{appId}:context:{context}:{model}", //the Channel of all objects from a context 34 | 7: Channel.keyPrefix+":{appId}:context:{context}:users:{user_id}:{model}", //the Channel of all objects from a context from an user 35 | 12: Channel.keyPrefix+":{appId}:{parent_model}:{parent_id}:{model}", //the Channel of all objects belong to a parent 36 | 14: Channel.keyPrefix+":{appId}:users:{user_id}:{parent_model}:{parent_id}:{model}",//the Channel of all comments from event 1 from user 16 37 | 20: Channel.keyPrefix+":{appId}:{model}:{id}", //the Channel of one item 38 | }; 39 | 40 | Channel.builtInModels = ['user', 'context']; 41 | 42 | Channel.prototype.model = function(model, id) { 43 | if (model) { 44 | if (Application.loadedAppModels[this.appId] !== undefined && Application.loadedAppModels[this.appId].schema !== undefined && Application.loadedAppModels[this.appId].schema[model]) { 45 | this.props.model = model; 46 | } else if (Channel.builtInModels.indexOf(model) !== -1) 47 | this.props.model = model; 48 | else 49 | throw new Error('Model "'+model+'" is not a valid model name'); 50 | 51 | this.mask |= Channel.MASKS.MODEL; 52 | } 53 | 54 | if (id) { 55 | this.props.modelId = id; 56 | this.mask |= Channel.MASKS.ID; 57 | } 58 | 59 | 60 | if (!model && !id) 61 | this.forceInvalid = true; 62 | 63 | return this; 64 | }; 65 | 66 | Channel.prototype.parent = function(parent) { 67 | if (parent && parent.model && parent.id) { 68 | this.mask |= Channel.MASKS.PARENT; 69 | this.props.parent = parent; 70 | } else 71 | this.forceInvalid = true; 72 | 73 | return this; 74 | } 75 | 76 | Channel.prototype.context = function(context) { 77 | if (context) { 78 | this.mask |= Channel.MASKS.CONTEXT; 79 | this.props.context = context; 80 | } else 81 | this.forceInvalid = true; 82 | 83 | return this; 84 | }; 85 | 86 | Channel.prototype.user = function(user) { 87 | if (user) { 88 | this.mask |= Channel.MASKS.USER; 89 | this.props.user = user; 90 | } else 91 | this.forceInvalid = true; 92 | 93 | return this; 94 | }; 95 | 96 | Channel.prototype.setFilter = function(filter) { 97 | if (filter) 98 | this.filter = filter; 99 | else 100 | this.forceInvalid = true; 101 | 102 | return this; 103 | }; 104 | 105 | Channel.prototype.isValid = function() { 106 | var mask = Channel.validChannels[this.mask]; 107 | 108 | //only built in models can have a subscription on all without a context 109 | if (this.mask === 4 && Channel.builtInModels.indexOf(this.props.model) === -1) { 110 | this.errorMessage = 'Only builtin models (user,context) can be subscribed to all without a context'; 111 | return false; 112 | } 113 | 114 | var result = !this.forceInvalid && (mask !== undefined); 115 | 116 | if (!result) 117 | this.errorMessage = 'Invalid channel "'+this.mask+'".'; 118 | 119 | return result; 120 | }; 121 | 122 | Channel.prototype.get = function(options) { 123 | if (!this.isValid()) 124 | throw new Error('Invalid channel with mask "'+this.mask+'"'); 125 | 126 | var validChannel = Channel.validChannels[this.mask]; 127 | 128 | validChannel = validChannel.replace('{appId}', this.appId); 129 | 130 | switch(this.mask) { 131 | case 4: { 132 | if (Channel.builtInModels.indexOf(this.props.model) === -1) 133 | throw new Error('Channel with mask "4" can only be used with built in models'); 134 | 135 | validChannel = validChannel.replace('{model}', this.props.model); 136 | 137 | break; 138 | } 139 | case 5: { // MASKS.CONTEXT | MASKS.MODEL 140 | validChannel = validChannel.replace('{context}', this.props.context); 141 | validChannel = validChannel.replace('{model}', this.props.model); 142 | 143 | break; 144 | } 145 | 146 | case 7: { // MASKS.CONTEXT | MASKS.USER | MASKS.MODEL 147 | validChannel = validChannel.replace('{context}', this.props.context); 148 | validChannel = validChannel.replace('{user_id}', this.props.user); 149 | validChannel = validChannel.replace('{model}', this.props.model); 150 | 151 | break; 152 | } 153 | 154 | case 12: { // MASKS.MODEL | MASKS.PARENT 155 | validChannel = validChannel.replace('{parent_model}', this.props.parent.model); 156 | validChannel = validChannel.replace('{parent_id}', this.props.parent.id); 157 | validChannel = validChannel.replace('{model}', this.props.model); 158 | 159 | break; 160 | } 161 | 162 | case 14: { // MASKS.USER | MASKS.MODEL | MASKS.PARENT 163 | validChannel = validChannel.replace('{parent_model}', this.props.parent.model); 164 | validChannel = validChannel.replace('{parent_id}', this.props.parent.id); 165 | validChannel = validChannel.replace('{model}', this.props.model); 166 | validChannel = validChannel.replace('{user_id}', this.props.user); 167 | 168 | break; 169 | } 170 | 171 | case 20: { // MASKS.MODEL | MASKS.ID 172 | validChannel = validChannel.replace('{model}', this.props.model); 173 | validChannel = validChannel.replace('{id}', this.props.modelId); 174 | 175 | break; 176 | } 177 | } 178 | 179 | if (this.filter) 180 | validChannel += ':filter:'+(new Buffer(JSON.stringify(this.filter))).toString('base64'); 181 | 182 | if (options) { 183 | if (options.deltas) 184 | validChannel += ':deltas'; 185 | } 186 | 187 | 188 | return validChannel; 189 | }; 190 | 191 | /** 192 | * 193 | * @param {Channel} channel 194 | * @returns {Channel} 195 | */ 196 | Channel.cloneFrom = function(channel) { 197 | var c = new Channel(channel.appId, channel.props); 198 | c.mask = channel.mask; 199 | 200 | return c; 201 | }; 202 | 203 | Channel.prototype.clone = function() { 204 | var c = new Channel(this.appId, this.props); 205 | c.mask = this.mask; 206 | c.filter = this.filter; 207 | 208 | return c; 209 | } 210 | 211 | module.exports = Channel; 212 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.4.4 2 | 3 | * Updated `apn` package which uses the new HTTP/2 protocol which should make much more reliable and faster 4 | * The array containing the devices' subscription in the device object have been moved to a dedicated redis key 5 | * Implemented configuration manager used for validating the config file 6 | * Subscription elements in the redis key now contain the device ID 7 | * Implemented `Subscription.removeAllSubscriptionsFromDevice` 8 | * `Subscription.getAllDevices` device identifier changed to "device.id|device.token" 9 | * `Subscription.remove` accepts token as an optional argument 10 | * `User.update` now returns the updated user object 11 | * Subscription methods should take `device.volatile.active` into account when adding/removing subscriptions 12 | * BUGFIX: `utils.testObject` has been rewritten and should be bug free now 13 | * BUGFIX: fixed incomplete filters when a complex filter contained the AND connector 14 | * BUGFIX: fixed object updates if an object field's contain " or ' characters 15 | * BUGFIX: fixed `Subscription.add` when `device.persistent` is missing 16 | * BUGFIX: Removed count LOCK key in redis if transaction fails 17 | 18 | # 0.4.3. 19 | 20 | * ES adapter updateObjects implemented with retry-on-conflict strategy 21 | * Fixed bug when updating multiple fields 22 | 23 | # 0.4.2 24 | 25 | * Fixed ES 'like' filter 26 | * Fixed ES geolocation sorting 27 | * Implemented System messages 28 | * Support for multi-sort 29 | * fixed model.GetFiltersFromChannel when 'and' 'or' operators are direct parents/children 30 | 31 | # 0.4.1 32 | 33 | * added default subscribe limit to Model.search 34 | * Bugfix: error handling when device is invalid 35 | * Implemented **like** filter 36 | * TelepatIndexedLists.get now returns the object if it's found 37 | * Bugfix: search no longer fails when an object has no mapping in ES 38 | * Bugfix: User() filters 39 | * ExpiredAuthorizationToken error status code should be 401 40 | 41 | # 0.4.0 42 | 43 | * Major performance improvements 44 | * AMQP messages are now compressed using LZ4 45 | * TelepatIndexedLists is a data structure on redis used to rapidly search 46 | for members 47 | * Simpliefied the database adapter, now they can be used in bulk operations 48 | * Device IDs are no longer stored in subscriptions, instead their tokens 49 | are stored in order to avoid retrieving the device object everytime 50 | * Filter builder is used by database adapters in order to form filters 51 | for querying the database 52 | * Moved some of the logic from the old adapter's functions to functions 53 | in Admin/Context/Model/etc 54 | 55 | # 0.3.0 56 | 57 | * Performance fix: AMQP queues/exchanges are only asserted once 58 | * Devices are created in an inactive keyspace. They should be activated 59 | when a client connects to a volatile transport 60 | * Implemented methods to activate/deactivate devices 61 | * Performance fix: incresed maxSockets for ElasticSearch 62 | * Performance fix: used agentKeepAlive for elasticSearch SDK 63 | * Ability to connect to multiple ES nodes, or just one if you want to 64 | use auto-discovery 65 | * Messaging clients has the exclusive flag making them have one queue 66 | per client 67 | * Implemented offset/limit for some methods 68 | * Implemented userMetadata use only by their respectiv owners 69 | * Replaced syslog_logger with Winston 70 | * Fixed a bug where updating objects with a field of type object would 71 | make the operation to merge the fields instead of replacing the whole 72 | object 73 | * Implemented caching for count queries 74 | 75 | # 0.2.8 76 | 77 | * Bugfix: Channel.isValid when mask is invalid 78 | * Bugfix: Channel when used with parent objects 79 | * Bugfix: `applicationHasContext` in elasticSearch adapter returns false instead of an error in callback 80 | * Bugfix: `modelCountByChannel` in elasticSearch adapter, context is optional 81 | * Bugfix: `modelCountByChannel` should now work with filters 82 | * `LoginProviderNotConfigured` error transformed into a more generic error `ServerNotConfigured` 83 | * Added support for aggregating results in `modelCountByChannel` 84 | * Added supoprt for object sort in `modelSearch` 85 | 86 | # 0.2.7 87 | 88 | * Fixed some bugs and crashes 89 | * Redis keys are being scanned untill cursor returns 0 90 | * Changed **email** property of user to **username** 91 | * Added `ExpiredAuthorizationToken` error 92 | 93 | # 0.2.6 94 | 95 | * User.get can now be called with an object containing ID or email 96 | * Fixed bug in AMQP message client which created unintended queues 97 | * `applicationDeleteModelSchema` now deletes all model siblings 98 | * Implemented `removeAllSubscriptionsFromDevice` 99 | * Fixed handling of errors on Application.loadAllApllications 100 | * Correct stack trace generation on TelepatError 101 | * Subscription and Device keys now contain the applicationID in them 102 | * Implemented `removeDevice` 103 | * Implemented `TelepatLogger` used for logging purposes 104 | * `syslogger` 105 | * `console_logger` 106 | * Fixed kafka/AzureSB crash 107 | 108 | # 0.2.5 109 | 110 | * Fixed various bugs 111 | * 2 New message queue clients: **Azure ServiceBus** and **AMQP (RabbitMQ)** 112 | * ElasticSearch should refresh its index on create/delete 113 | * Configuration variables for ElasticSearch adapter get/subscribe result limit + paginated results for subscribe 114 | * Model.countByChannel fixed 115 | * Rewored how loadedAppModels work: every package should populate this at boot up. App.get loads from redis (cached) 116 | * All message queue clients now have a broadcast method which broadcasts messages to all consumers on a channel/topic 117 | 118 | # 0.2.4 119 | 120 | * Standardized Errors with TelepatError object 121 | * Implemented Delta.formPatches to more easily form patches from objects 122 | * Moved messaging client from telepat-worker to telepat-models to be reusable by other components 123 | * Added 'type' field to application, context and admin objects 124 | 125 | # 0.2.3 126 | 127 | * Replaced couchbase with elasticsearch through adapters 128 | * Fixed many bugs 129 | * Added email field to Deltas (used by user operations) 130 | * Admin create throws 409 error when admin with that email address already exists 131 | * Implemented admin.delete 132 | * getDevices returns the corect error message when missing 133 | * Implemented Delta.processObject which can be used by all update operations 134 | 135 | # 0.2.2 136 | 137 | * Release full of bug fixes 138 | 139 | # 0.2.1 140 | 141 | * Important performance issue fixed: all Models operations require context id when getting the object from database 142 | 143 | # 0.2.0 144 | 145 | * Implemented Channel and Delta classes to further separate code logic 146 | * Added password field to user objects 147 | * Fixed subscribe.remove and .add 148 | * Fixed application schema keys 149 | * Fixed device persistent udid key 150 | * Return 404 error when unsubscribing with an invalid subscription 151 | 152 | # 0.1.2 153 | 154 | * Added LICENSE and README files 155 | * get All Models and get All Contexts now return an array in the callback instead of hash map 156 | 157 | # 0.1.0 158 | 159 | * Initial Release 160 | -------------------------------------------------------------------------------- /lib/User.js: -------------------------------------------------------------------------------- 1 | var Application = require('./Application'); 2 | var async = require('async'); 3 | var FilterBuilder = require('../utils/filterbuilder').FilterBuilder; 4 | var guid = require('uuid'); 5 | var TelepatError = require('./TelepatError'); 6 | 7 | /** 8 | * Gets an user by email address 9 | * @param user Object object containing the id or email address of the user 10 | * @param callback 11 | * @constructor 12 | */ 13 | function User(user, appId, callback) { 14 | if (user.id) { 15 | Application.datasource.dataStorage.getObjects([user.id], function(errs, results) { 16 | if (errs && errs.length > 0) return callback(errs[0]); 17 | callback(null, results[0]); 18 | }); 19 | } else if (user.username) { 20 | var filters = (new FilterBuilder('and')).addFilter('is', {application_id: appId}).addFilter('is', {username: user.username}); 21 | Application.datasource.dataStorage.searchObjects({modelName: 'user', filters: filters}, function(err, results) { 22 | if (err) 23 | return callback(err); 24 | if (!results.length) 25 | return callback(new TelepatError(TelepatError.errors.UserNotFound)); 26 | callback(null, results[0]); 27 | }); 28 | } 29 | } 30 | 31 | /** 32 | * Loads the configuration spec file. Automatically loaded at module require. 33 | */ 34 | User.load = function() { 35 | User._model = require('../models/user.json'); 36 | 37 | if (!User._model) { 38 | Application.logger.emergency('Model \'user\' spec file does not exist.'); 39 | process.exit(-1); 40 | } 41 | }; 42 | 43 | /** 44 | * Creates a user 45 | * @param props Object Properties of the user. 46 | * @param callback 47 | */ 48 | User.create = function(props, appId, callback) { 49 | var self = this; 50 | props.id = props.id || guid.v4(); 51 | props.application_id = appId; 52 | props.created = Math.floor((new Date()).getTime()/1000); 53 | props.modified = props.created; 54 | props.type = 'user'; 55 | 56 | if (!props.hasOwnProperty('friends')) 57 | props.friends = []; 58 | 59 | if (!props.hasOwnProperty('devices')) 60 | props.devices = []; 61 | 62 | var userMetadata = { 63 | id: guid.v4(), 64 | user_id: props.id, 65 | application_id: appId, 66 | type: 'user_metadata' 67 | }; 68 | 69 | User({username: props.username}, appId, function(err, result) { 70 | if (err && err.status == 404) { 71 | Application.datasource.dataStorage.createObjects([props, userMetadata], function(errs) { 72 | if (errs && errs.length) { 73 | errs.forEach(function(error) { 74 | Application.logger.error(error.message); 75 | }); 76 | return callback(new TelepatError(TelepatError.errors.ServerFailure, ['failed to create user.'])); 77 | } 78 | 79 | callback(null, props); 80 | }); 81 | } else { 82 | callback(new TelepatError(TelepatError.errors.UserAlreadyExists)); 83 | } 84 | }); 85 | } 86 | 87 | User.count = function(appId, callback) { 88 | var filters = null; 89 | if (appId) 90 | filters = (new FilterBuilder()).addFilter('is', {application_id: appId}); 91 | 92 | Application.datasource.dataStorage.countObjects({modelName: 'user', filters: filters}, callback); 93 | } 94 | 95 | /** 96 | * Updates a user 97 | * @param patches Object[] The new/updated properties of the user. 98 | * @param callback 99 | */ 100 | User.update = function(patches, callback) { 101 | Application.datasource.dataStorage.updateObjects(patches, function(errs, dbObjects) { 102 | if (errs.length) { 103 | return callback(errs[0]); 104 | } 105 | 106 | var objId = Object.keys(dbObjects)[0]; 107 | 108 | callback(null, dbObjects[objId]); 109 | }); 110 | } 111 | 112 | /** 113 | * Deletes a user. 114 | * @param id string Email address of the user. 115 | * @param callback 116 | */ 117 | User.delete = function(id, appId, callback) { 118 | var user = null; 119 | 120 | async.series([ 121 | function(callback1) { 122 | User({id: id}, appId, function(err, result) { 123 | if (err) return callback1(err); 124 | 125 | user = result; 126 | callback1(); 127 | }); 128 | }, 129 | function deleteSubscriptions(callback1) { 130 | async.each(user.devices, function(deviceId, c1) { 131 | Application.redisClient.get('blg:devices:'+deviceId, function(err, response) { 132 | if (err) return c1(err); 133 | 134 | if (response) { 135 | var device = JSON.parse(response); 136 | if (device.subscriptions) { 137 | var transaction = Application.redisClient.multi(); 138 | 139 | device.subscriptions.each(function(sub) { 140 | transaction.srem([sub, deviceId]); 141 | }); 142 | 143 | transaction.del('blg:devices:'+deviceId); 144 | 145 | transaction.exec(function(err, res) { 146 | if (err) Application.logger.warning('Failed removing device from subscriptions: '+err.message); 147 | }); 148 | } 149 | } 150 | c1(); 151 | }); 152 | }); 153 | callback1(); 154 | }, 155 | function(callback1) { 156 | var usrObj = {}; 157 | usrObj[id] = 'user'; 158 | Application.datasource.dataStorage.deleteObjects(usrObj, function(errs) { 159 | callback1(errs && errs.length > 1 ? errs[0] : null); 160 | }); 161 | }, 162 | function(callback1) { 163 | var deleteUserObjects = function(obj) { 164 | var deleteObjects = {}; 165 | async.each(obj, function(o, c) { 166 | deleteObjects[o.id] = o.type; 167 | c(); 168 | }, function() { 169 | Application.datasource.dataStorage.deleteObjects(deleteObjects, function(errs) { 170 | if (errs && errs.length > 1) { 171 | Application.logger.warning('Failed to delete '+errs.length+' user objects.'); 172 | } 173 | }); 174 | }); 175 | }; 176 | var filter = (new FilterBuilder()).addFilter('is', {user_id: id}); 177 | Application.datasource.dataStorage.searchObjects({filters: filter, fields: ['id', 'type'], scanFunction: deleteUserObjects}, callback1); 178 | } 179 | ], callback); 180 | }; 181 | 182 | User.getAll = function(appId, offset, limit, callback) { 183 | var filters = (new FilterBuilder()).addFilter('is', {application_id: appId}); 184 | Application.datasource.dataStorage.searchObjects({modelName: 'user', filters: filters, offset: offset, limit: limit}, callback); 185 | }; 186 | 187 | User.search = function(appId, filters, offset, limit, callback) { 188 | var filterBuilder = (new FilterBuilder()).addFilter('is', {application_id: appId}); 189 | 190 | Object.keys(filters).forEach(function (field) { 191 | var fieldObject = {}; 192 | fieldObject[field] = filters[field]; 193 | filterBuilder.addFilter('like', fieldObject); 194 | }); 195 | 196 | Application.datasource.dataStorage.searchObjects({modelName: 'user', filters: filterBuilder, offset: offset, limit: limit}, callback); 197 | }; 198 | 199 | User.getMetadata = function(userId, callback) { 200 | var filters = (new FilterBuilder()).addFilter('is', {user_id: userId}); 201 | Application.datasource.dataStorage.searchObjects({modelName: 'user_metadata', filters: filters}, function(err, results) { 202 | if (err) return callback(err); 203 | callback(null, results[0]); 204 | }); 205 | }; 206 | 207 | User.updateMetadata = function(userId, patches, callback) { 208 | Application.datasource.dataStorage.updateObjects(patches, function(errs) { 209 | callback(errs && errs.length ? errs[0] : null); 210 | }); 211 | }; 212 | 213 | module.exports = User; 214 | -------------------------------------------------------------------------------- /lib/TelepatError.js: -------------------------------------------------------------------------------- 1 | var sprintf = require('sprintf-js').vsprintf; 2 | 3 | Error.stackTraceLimit = Infinity; 4 | 5 | var TelepatError = function(error, placeholders) { 6 | Error.captureStackTrace(this, this); 7 | this.name = Object.keys(error)[0]; 8 | this.code = error.code; 9 | this.message = placeholders ? sprintf(error.message, placeholders) : error.message; 10 | this.args = placeholders; 11 | this.status = error.status; 12 | }; 13 | 14 | TelepatError.prototype = Object.create(Error.prototype); 15 | 16 | TelepatError.errors = { 17 | ServerNotAvailable: { 18 | code: '001', 19 | message: 'The API server is unable to fulfil your request. Try again later', 20 | status: 503 21 | }, 22 | ServerFailure: { 23 | code: '002', 24 | message: 'API internal server error: %s', 25 | status: 500 26 | }, 27 | NoRouteAvailable: { 28 | code: '003', 29 | message: 'There is no route with this URL path', 30 | status: 404 31 | }, 32 | MissingRequiredField: { 33 | code: '004', 34 | message: 'Request body is missing a required field: %s', 35 | status: 400 36 | }, 37 | RequestBodyEmpty: { 38 | code: '005', 39 | message: 'Required request body is empty', 40 | status: 400, 41 | }, 42 | InvalidContentType: { 43 | code: '006', 44 | message: 'Request content type must be application/json', 45 | status: 415 46 | }, 47 | ApiKeySignatureMissing: { 48 | code: '007', 49 | message: 'API key is missing from the request headers', 50 | status: 400 51 | }, 52 | InvalidApikey: { 53 | code: '008', 54 | message: 'API key is not valid for this application', 55 | status: 401 56 | }, 57 | DeviceIdMissing: { 58 | code: '009', 59 | message: 'Required device ID header is missing', 60 | status: 400 61 | }, 62 | ApplicationIdMissing: { 63 | code: '010', 64 | message: 'Required application ID header is missing', 65 | status: 400 66 | }, 67 | ApplicationNotFound: { 68 | code: '011', 69 | message: 'Requested application with ID "%s" does not exist', 70 | status: 404 71 | }, 72 | ApplicationForbidden: { 73 | code: '012', 74 | message: 'This application does not belong to you', 75 | status: 401 76 | }, 77 | AuthorizationMissing: { 78 | code: '013', 79 | message: 'Authorization header is not present', 80 | status: 401 81 | }, 82 | InvalidAuthorization: { 83 | code: '014', 84 | message: 'Invalid authorization: %s', 85 | status: 401 86 | }, 87 | OperationNotAllowed: { 88 | code: '015', 89 | message: 'You don\'t have the necessary privileges for this operation', 90 | status: 403 91 | }, 92 | AdminBadLogin: { 93 | code: '016', 94 | message: 'Wrong user email address or password', 95 | status: 401 96 | }, 97 | AdminAlreadyAuthorized: { 98 | code: '017', 99 | message: 'Admin with that email address is already authorized in this application', 100 | status: 409 101 | }, 102 | AdminDeauthorizeLastAdmin: { 103 | code: '018', 104 | message: 'Cannot remove yourself from the application because you\'re the only authorized admin', 105 | status: 409 106 | }, 107 | AdminNotFoundInApplication: { 108 | code: '019', 109 | message: 'Admin with email address %s does not belong to this application', 110 | status: 404 111 | }, 112 | ContextNotFound: { 113 | code: '020', 114 | message: 'Context not found', 115 | status: 404 116 | }, 117 | ContextNotAllowed: { 118 | code: '021', 119 | message: 'This context doesn\'t belong to you', 120 | status: 403 121 | }, 122 | ApplicationSchemaModelNotFound: { 123 | code: '022', 124 | message: 'Application with ID %s does not have a model named %s', 125 | status: 404 126 | }, 127 | UserNotFound: { 128 | code: '023', 129 | message: 'User not found', 130 | status: 404 131 | }, 132 | InvalidApplicationUser: { 133 | code: '024', 134 | message: 'User does not belong to this application', 135 | status: 404 136 | }, 137 | DeviceNotFound: { 138 | code: '025', 139 | message: 'Device with ID %s not found', 140 | status: 404 141 | }, 142 | InvalidContext: { 143 | code: '026', 144 | message: 'Context with id %s does not belong to app with id %s', 145 | status: 403 146 | }, 147 | InvalidChannel: { 148 | code: '027', 149 | message: 'Channel is invalid: %s', 150 | status: 400 151 | }, 152 | InsufficientFacebookPermissions: { 153 | code: '028', 154 | message: 'Insufficient facebook permissions: %s ', 155 | status: 400 156 | }, 157 | UserAlreadyExists: { 158 | code: '029', 159 | message: 'User already exists', 160 | status: 409 161 | }, 162 | AdminAlreadyExists: { 163 | code: '030', 164 | message: 'Admin already exists', 165 | status: 409 166 | }, 167 | UserBadLogin: { 168 | code: '031', 169 | message: 'User email address or password do not match', 170 | status: 401 171 | }, 172 | UnspecifiedError: { 173 | code: '032', 174 | message: 'Unspecified error', 175 | status: 500 176 | }, 177 | AdminNotFound: { 178 | code: '033', 179 | message: 'Admin not found', 180 | status: 404 181 | }, 182 | ObjectNotFound: { 183 | code: '034', 184 | message: 'Object model %s with ID %s not found', 185 | status: 404 186 | }, 187 | ParentObjectNotFound: { 188 | code: '035', 189 | message: 'Unable to create: parent "%s" with ID "%s" does not exist', 190 | status: 404 191 | }, 192 | InvalidObjectRelationKey: { 193 | code: '036', 194 | message: 'Unable to create: parent relation key "%s" is not valid. Must be at most %s', 195 | status: 400 196 | }, 197 | SubscriptionNotFound: { 198 | code: '037', 199 | message: 'Subscription not found', 200 | status: 404 201 | }, 202 | InvalidFieldValue: { 203 | code: '038', 204 | message: 'Invalid field value: %s', 205 | status: 400 206 | }, 207 | ClientBadRequest: { 208 | code: '039', 209 | message: 'Generic bad request error: %s', 210 | status: 400 211 | }, 212 | MalformedAuthorizationToken: { 213 | code: '040', 214 | message: 'Malformed authorization token', 215 | status: 400 216 | }, 217 | InvalidAdmin: { 218 | code: '041', 219 | message: 'Invalid admin', 220 | status: 401 221 | }, 222 | InvalidPatch: { 223 | code: '042', 224 | message: 'Invalid patch: %s', 225 | status: 400 226 | }, 227 | ApplicationHasNoSchema: { 228 | code: '043', 229 | message: 'Could not fulfill request because application has no schema defined', 230 | status: 501 231 | }, 232 | InvalidLoginProvider: { 233 | code: '044', 234 | message: 'Invalid login provider. Possible choices: %s', 235 | status: 400 236 | }, 237 | ServerNotConfigured: { 238 | code: '045', 239 | message: 'Unable to fullfill request because the server has not been configured: "%s"', 240 | status: 501 241 | }, 242 | ExpiredAuthorizationToken: { 243 | code: '046', 244 | message: 'Expired authorization token', 245 | status: 401 246 | }, 247 | UnconfirmedAccount: { 248 | code: '047', 249 | message: 'This user account has not been confirmed', 250 | status: 403 251 | }, 252 | QueryError: { 253 | code: '048', 254 | message: 'Failed to parse query filter: %s', 255 | status: 400 256 | }, 257 | TilNotFound: { 258 | code: '049', 259 | message: 'TelepatIndexedList with name "%s" does not exist', 260 | status: 404 261 | }, 262 | DeviceInvalid: { 263 | code: '050', 264 | message: 'Device with ID %s is invalid: %s', 265 | status: 400 266 | }, 267 | ServerConfigurationFailure: { 268 | code: '051', 269 | message: 'Server configuration failure: %s', 270 | status: 500 271 | } 272 | } 273 | 274 | module.exports = TelepatError; 275 | -------------------------------------------------------------------------------- /utils/utils.js: -------------------------------------------------------------------------------- 1 | var lz4Module = require('lz4'); 2 | var stream = require('stream'); 3 | var async = require('async'); 4 | 5 | /** 6 | * Transform the object that is sent in the request body in the subscribe endpoint so its compatible with 7 | * the elasticsearch query object. 8 | * @param filterObject Object 9 | * @example 10 | *
{
11 | "or": [
12 | {
13 | "and": [
14 | {
15 | "is": {
16 | "gender": "male",
17 | "age": 23
18 | }
19 | },
20 | {
21 | "range": {
22 | "experience": {
23 | "gte": 1,
24 | "lte": 6
25 | }
26 | }
27 | }
28 | ]
29 | },
30 | {
31 | "and": [
32 | {
33 | "like": {
34 | "image_url": "png",
35 | "website": "png"
36 | }
37 | }
38 | ]
39 | }
40 | ]
41 | }
42 | */
43 | var parseQueryObject = function(filterObject) {
44 | var objectKey = Object.keys(filterObject)[0];
45 | var result = {};
46 | result[objectKey] = [];
47 |
48 | for(var f in filterObject[objectKey]) {
49 | var filterObjectKey = Object.keys(filterObject[objectKey][f])[0];
50 | var filterType = null;
51 |
52 | if (filterObjectKey == 'and' || filterObjectKey == 'or') {
53 | result[objectKey].push(parseQueryObject(filterObject[objectKey][f]));
54 | continue;
55 | }
56 |
57 | switch(filterObjectKey) {
58 | case 'is': {
59 | filterType = 'term';
60 | break;
61 | }
62 | case 'like': {
63 | filterType = 'text';
64 | break;
65 | }
66 | default: {
67 | var otherFilters = {};
68 | otherFilters[filterObjectKey] = {};
69 |
70 | for(var prop in filterObject[objectKey][f][filterObjectKey]) {
71 | otherFilters[filterObjectKey][prop] = filterObject[objectKey][f][filterObjectKey][prop];
72 | }
73 |
74 | result[objectKey].push(otherFilters);
75 | continue;
76 | }
77 | }
78 |
79 | for(var prop in filterObject[objectKey][f][filterObjectKey]) {
80 | var p = {};
81 | p[filterType] = {};
82 | p[filterType][prop] = filterObject[objectKey][f][filterObjectKey][prop];
83 | result[objectKey].push(p);
84 | }
85 | }
86 |
87 | return result;
88 | };
89 |
90 | /**
91 | * Tests an object against a query object.
92 | * @param Object object Database item
93 | * @param Object query The simplified query object (not the elasticsearch one).
94 | * @returns {boolean}
95 | */
96 | function testObject(object, query) {
97 | if (typeof object != 'object')
98 | return false;
99 |
100 | if (typeof query != 'object')
101 | return false;
102 |
103 | var mainOperator = Object.keys(query)[0];
104 |
105 | if (mainOperator != 'and' && mainOperator != 'or')
106 | return false;
107 |
108 | var result = null;
109 | var partialResult = null
110 |
111 | function updateResult(result, partial) {
112 | //if result is not initialised, use the value of the operation
113 | //otherwise if it had a value from previous operations, combine the previous result with result from
114 | // the current operation
115 | return result === null ? partialResult :(mainOperator == 'and') ? result && partialResult :
116 | result || partialResult;
117 | }
118 |
119 | for(var i in query[mainOperator]) {
120 | if (typeof query[mainOperator][i] != 'object')
121 | continue;
122 |
123 | var operation = Object.keys(query[mainOperator][i])[0];
124 |
125 | operationsLoop:
126 | for(var property in query[mainOperator][i][operation]) {
127 | switch(operation) {
128 | case 'is': {
129 | partialResult = object[property] == query[mainOperator][i][operation][property];
130 |
131 | break;
132 | }
133 |
134 | case 'like': {
135 | partialResult = object[property].toString().search(query[mainOperator][i][operation][property]) !== -1;
136 |
137 | break;
138 | }
139 |
140 | case 'range': {
141 | if (typeof query[mainOperator][i][operation][operation][property] != 'object')
142 | continue;
143 |
144 | rangeQueryLoop:
145 | for(var rangeOperator in query[mainOperator][i][operation][property]) {
146 | var objectPropValue = parseInt(object[property]);
147 | var queryPropValue = parseInt(query[mainOperator][i][operation][property][rangeOperator]);
148 |
149 | switch(rangeOperator) {
150 | case 'gte': {
151 | partialResult = objectPropValue >= queryPropValue;
152 |
153 | break;
154 | }
155 |
156 | case 'gt': {
157 | partialResult = objectPropValue > queryPropValue;
158 |
159 | break;
160 | }
161 |
162 | case 'lte': {
163 | partialResult = objectPropValue <= queryPropValue;
164 |
165 | break;
166 | }
167 |
168 | case 'lt': {
169 | partialResult = objectPropValue < queryPropValue;
170 |
171 | break;
172 | }
173 |
174 | default: {
175 | continue rangeQueryLoop;
176 | }
177 | }
178 |
179 | result = updateResult(result, partialResult);
180 | }
181 |
182 | break;
183 | }
184 |
185 | case 'or':
186 | case 'and': {
187 | //console.log(query[mainOperator][i]);
188 | partialResult = testObject(object, query[mainOperator][i]);
189 |
190 | break;
191 | }
192 |
193 | default: {
194 | continue operationsLoop;
195 | }
196 | }
197 |
198 | result = updateResult(result, partialResult);
199 | }
200 | }
201 |
202 | return !!result;
203 | }
204 |
205 | var lz4 = (function() {
206 |
207 | /**
208 | * @callback lz4ResultCb
209 | * @param {Buffer} result The result of compression/decompression
210 | */
211 | /**
212 | * Only used internally to avoid code dupe
213 | * @param {string|Buffer} data
214 | * @param {int} operation 0 for compression, 1 for decompression
215 | * @param {lz4ResultCb} callback
216 | */
217 | var doWork = function(data, operation, callback) {
218 | var lz4Stream = null;
219 |
220 | if (operation == 0)
221 | lz4Stream = lz4Module.createEncoderStream();
222 | else if (operation == 1)
223 | lz4Stream = lz4Module.createDecoderStream();
224 |
225 | var outputStream = new stream.Writable();
226 | var result = new Buffer('');
227 |
228 | outputStream._write = function(chunk, encoding, callback1) {
229 | result = Buffer.concat([result, chunk]);
230 | callback1();
231 | };
232 |
233 | outputStream.on('finish', function() {
234 | callback(result);
235 | });
236 |
237 | var inputStream = new stream.Readable();
238 | inputStream.push(data);
239 | inputStream.push(null);
240 |
241 | inputStream.pipe(lz4Stream).pipe(outputStream);
242 | }
243 |
244 | return {
245 | /**
246 | * LZ4 compress a string
247 | * @param {string} string
248 | * @param {lz4ResultCb} callback
249 | */
250 | compress: function(string, callback) {
251 | doWork(string, 0, callback);
252 | },
253 | /**
254 | * LZ4 decompress a string
255 | * @param {Buffer} buffer
256 | * @param {lz4ResultCb} callback
257 | */
258 | decompress: function(buffer, callback) {
259 | doWork(buffer, 1, callback);
260 | }
261 | };
262 | })();
263 |
264 | var scanRedisKeysPattern = function(pattern, redisInstance, callback) {
265 | var redisScanCursor = -1;
266 | var results = [];
267 |
268 | var scanAndGet = function(callback1) {
269 | redisInstance.scan([redisScanCursor == -1 ? 0 : redisScanCursor,
270 | 'MATCH', pattern, 'COUNT', 100000], function(err, partialResults) {
271 | if (err) return callback1(err);
272 |
273 | redisScanCursor = partialResults[0];
274 | results = results.concat(partialResults[1]);
275 |
276 | callback1();
277 | });
278 | };
279 |
280 | async.during(
281 | function(callback1) {
282 | callback1(null, redisScanCursor != 0);
283 | },
284 | scanAndGet,
285 | function(err) {
286 | callback(err, results);
287 | }
288 | );
289 | };
290 |
291 | //console.log(JSON.stringify(getQueryKey(JSON.parse('{"or":[{"and":[{"is":{"gender":"male","age":23}},{"range":{"experience":{"gte":1,"lte":6}}}]},{"and":[{"like":{"image_url":"png","website":"png"}}]}]}'))));
292 | //console.log(parseQueryObject(JSON.parse('{"or":[{"and":[{"is":{"gender":"male","age":23}},{"range":{"experience":{"gte":1,"lte":6}}}]},{"and":[{"like":{"image_url":"png","website":"png"}}]}]}')));
293 |
294 | module.exports = {
295 | parseQueryObject: parseQueryObject,
296 | testObject: testObject,
297 | scanRedisKeysPattern: scanRedisKeysPattern,
298 | lz4: lz4
299 | };
300 |
--------------------------------------------------------------------------------
/lib/ConfigurationManager.js:
--------------------------------------------------------------------------------
1 | var dot = require('dot-object');
2 | var validator = require('validator');
3 | var TelepatError = require('./TelepatError');
4 | var async = require('async');
5 | var fs = require('fs');
6 | var clone = require('clone');
7 | var Application = require('./Application');
8 |
9 | var ConfigurationManager = function(specFile, configFile) {
10 | this.specFile = specFile;
11 | this.configFile = configFile;
12 |
13 | /**
14 | * This object holds the collection of all groups containing the grouped variables
15 | * @type {{string}}
16 | */
17 | this.exclusivityGroups = {};
18 |
19 | /**
20 | *
21 | * @type {{string[]}}
22 | */
23 | this.foundExclusiveVariables = {};
24 | };
25 |
26 | ConfigurationManager.prototype.load = function(callback) {
27 | var self = this;
28 |
29 | async.series([
30 | function(callback1) {
31 | fs.readFile(self.specFile, {encoding: 'utf8'}, function(err, contents) {
32 | if (err) {
33 | callback1(TelepatError(TelepatError.errors.ServerConfigurationFailure, [err.message]));
34 | } else {
35 | self.spec = JSON.parse(contents);
36 | callback1();
37 | }
38 | });
39 | },
40 | function(callback1) {
41 | fs.readFile(self.configFile, {encoding: 'utf8'}, function(err, contents) {
42 | if (err) {
43 | callback1(TelepatError(TelepatError.errors.ServerConfigurationFailure, [err.message]));
44 | } else {
45 | self.config = JSON.parse(contents);
46 | callback1();
47 | }
48 | });
49 | }
50 | ], function(err) {
51 | if (err)
52 | throw err;
53 | else {
54 | self._loadExclusivityGroups();
55 | callback();
56 | }
57 | });
58 | };
59 |
60 | ConfigurationManager.prototype._loadExclusivityGroups = function(spec) {
61 | spec = spec || this.spec.root;
62 |
63 | for(var i in spec) {
64 | if (spec[i].exclusive_group) {
65 | if (!this.exclusivityGroups[spec[i].exclusive_group])
66 | this.exclusivityGroups[spec[i].exclusive_group] = [spec[i].name];
67 | else
68 | this.exclusivityGroups[spec[i].exclusive_group].push(spec[i].name);
69 | }
70 | if (spec[i].root) {
71 | this._loadExclusivityGroups(spec[i].root);
72 | }
73 | }
74 | };
75 |
76 | /**
77 | *
78 | * @param {object} [spec]
79 | * @param {object} [config]
80 | * @returns {boolean|TelepatError}
81 | */
82 | ConfigurationManager.prototype._validate = function(spec, config, rootName) {
83 | var result = true;
84 | spec = spec || this.spec.root;
85 | config = config || this.config;
86 | rootName = rootName || '';
87 |
88 | for(var s in spec) {
89 | var varName = spec[s].name;
90 |
91 | if (spec[s].exclusive_group && config[spec[s].name]) {
92 | if (!this.foundExclusiveVariables[spec[s].exclusive_group])
93 | this.foundExclusiveVariables[spec[s].exclusive_group] = [varName];
94 | else
95 | this.foundExclusiveVariables[spec[s].exclusive_group].push(varName);
96 | }
97 |
98 | var varValue = config[spec[s].name];
99 |
100 | if (spec[s].env_var && process.env[spec[s].env_var] && !spec[s].root) {
101 | var theEnvVar = process.env[spec[s].env_var];
102 |
103 | if (spec[s].type == 'array') {
104 | varValue = theEnvVar ? theEnvVar.split(' ') : undefined;
105 | } else if (spec[s].type == 'bool') {
106 | varValue = !!theEnvVar;
107 | } else {
108 | varValue = theEnvVar
109 | }
110 | config[spec[s].name] = varValue;
111 | } else if (spec[s].root && !spec[s].optional && !varValue) {
112 | config[spec[s].name] = varValue = {};
113 | }
114 |
115 | result = result && this.verifyVariable(varValue, spec[s], rootName);
116 |
117 | if (result instanceof TelepatError)
118 | return result;
119 | }
120 |
121 | return result;
122 | };
123 |
124 | ConfigurationManager.prototype.test = function() {
125 | return this._validate();
126 | }
127 |
128 | /**
129 | *
130 | * @param {*} variable
131 | * @param {Object} specVariable
132 | * @param {string} specVariable.name Name of the variable
133 | * @param {string} specVariable.env_var Name of the environment variable to check for, if it's not present in the file
134 | * @param {string} specVariable.type The type of the variable (int, float, array, object, string, bool)
135 | * @param {string} specVariable.array_type The type of the array's elements
136 | * @param {string} specVariable.optional The test passes if the variable is not set, null or empty
137 | * @param {string} specVariable.exclusive_group Exclusivity group for a config variable. Only 1 variable can be in this
138 | * group.
139 | * @param {array} specVariable.enum This variable can only have these values
140 | * @param {Object} specVariable.required_by This variable is verified only when a specific variable has a certain value
141 | * @param {array} specVariable.root Allows for nested objects
142 | * @return {boolean|TelepatError} true if variable passed or an error describing the problem if it didn't pass
143 | */
144 | ConfigurationManager.prototype.verifyVariable = function(variable, specVariable, rootName) {
145 | if (!specVariable.name) {
146 | console.log('Spec file ' + this.specFile + ' has a variable which is missing the "name"' +
147 | ' property');
148 | return true;
149 | }
150 |
151 | var fullVarName = rootName + '.' + specVariable.name;
152 |
153 | if (specVariable.required_by) {
154 | var requiredVarName = Object.keys(specVariable.required_by)[0];
155 | var requiredVarValue = specVariable.required_by[requiredVarName];
156 |
157 | if (dot.pick(requiredVarName, this.config) != requiredVarValue)
158 | return true;
159 | }
160 |
161 | // because nested objects don't have environment variables (only their children) we need to simmulate an empty object
162 | // in the loaded configuration
163 | if (specVariable.root && variable instanceof Object && !Object.keys(variable).length)
164 | return this._validate(specVariable.root, dot.pick(fullVarName.slice(1), this.config), fullVarName);
165 |
166 | if (specVariable.optional)
167 | return true;
168 | //if the value in the config file doen't exist and it also doesn't belong in a exclusive group, it means that it's
169 | //a mandatory config var which is missing
170 | else if (!variable && !specVariable.exclusive_group) {
171 | return new TelepatError(TelepatError.errors.ServerConfigurationFailure, [fullVarName +
172 | ' is mising from the configuration']);
173 | } else if (!variable && specVariable.exclusive_group)
174 | return true;
175 |
176 | if (specVariable.enum && !this.enumVerifier(variable, specVariable.enum)) {
177 | return new TelepatError(TelepatError.errors.ServerConfigurationFailure, [fullVarName + ' can only have these ' +
178 | 'values: "'+specVariable.enum.join(' ')+'"']);
179 | }
180 |
181 | if (!this.typeVerifier(variable, specVariable.type)) {
182 | return new TelepatError(TelepatError.errors.ServerConfigurationFailure, ['Invalid type for variable '
183 | + fullVarName + ', must be "'+specVariable.type+'"']);
184 | }
185 |
186 | if (specVariable.array_type && !this.arrayTypeVerifier(variable, specVariable.array_type))
187 | return new TelepatError(TelepatError.errors.ServerConfigurationFailure, ['Invalid type for variable '
188 | + fullVarName + ' or array has wrong type for its elements']);
189 |
190 | if (specVariable.exclusive_group) {
191 | // !(if the value doesn't exist in the config file but it's a part of an exclusive group)
192 | if (!(!variable && this.foundExclusiveVariables[specVariable.exclusive_group]) && !this.exclusivityVerifier(specVariable.exclusive_group))
193 | return new TelepatError(TelepatError.errors.ServerConfigurationFailure, ['At most one of these variables "'
194 | + this.exclusivityGroups[specVariable.exclusive_group].join(' ') + '" can be present']);
195 | }
196 |
197 | return specVariable.root ? this._validate(specVariable.root, dot.pick(fullVarName.slice(1), this.config), fullVarName) : true;
198 | };
199 |
200 | /**
201 | *
202 | * @param {*} variable
203 | * @param {string} type
204 | * @returns {boolean}
205 | */
206 | ConfigurationManager.prototype.typeVerifier = function(variable, type) {
207 | switch(type) {
208 | case 'int': {
209 | return validator.isInt('' + variable);
210 | }
211 |
212 | case 'float': {
213 | return validator.isFloat('' + variable);
214 | }
215 |
216 | case 'array': {
217 | return Array.isArray(variable);
218 | }
219 |
220 | case 'object': {
221 | return (variable instanceof Object);
222 | }
223 |
224 | case 'string': {
225 | return (typeof variable) === 'string';
226 | }
227 |
228 | case 'bool': {
229 | return (typeof variable) === 'boolean';
230 | }
231 |
232 | default:
233 | return true;
234 | }
235 | };
236 |
237 | /**
238 | * Checks the elements of an array if the are of the same specified type
239 | * @param {*} array
240 | * @param {string} type
241 | * @returns {boolean} True of all elements pass the type test
242 | */
243 | ConfigurationManager.prototype.arrayTypeVerifier = function(array, type) {
244 | if (!Array.isArray(array))
245 | return false;
246 |
247 | for(var i in array) {
248 | if (!this.typeVerifier(array[i], type))
249 | return false;
250 | }
251 | };
252 |
253 | /**
254 | * Checks if the value is found in the enum array
255 | * @param {*} value
256 | * @param {Array} array
257 | * @returns {boolean} True of all elements pass the type test
258 | */
259 | ConfigurationManager.prototype.enumVerifier = function(value, array) {
260 | return array.indexOf(value) !== -1;
261 | };
262 |
263 | /**
264 | * Verifies if the group hasn't been already created. Only one variable in this group should exist.
265 | * @param {string} group
266 | * @returns {boolean} returns true if the group is empty
267 | */
268 | ConfigurationManager.prototype.exclusivityVerifier = function(group) {
269 | //we use .length == 1 because in _validate() we insert this variable here before calling this function
270 | return this.foundExclusiveVariables[group] && this.foundExclusiveVariables[group].length === 1;
271 | };
272 |
273 | module.exports = ConfigurationManager;
--------------------------------------------------------------------------------
/lib/Application.js:
--------------------------------------------------------------------------------
1 | var async = require('async');
2 | var FilterBuilder = require('../utils/filterbuilder').FilterBuilder;
3 | var guid = require('uuid');
4 | var TelepatError = require('./TelepatError');
5 |
6 | /**
7 | * @typedef {{
8 | * relationType: string,
9 | * parentModel: string
10 | * }} Relation
11 | */
12 |
13 | /**
14 | * @typedef {{
15 | * meta_read_acl: Number,
16 | * read_acl: Number,
17 | * write_acl: Number,
18 | * properties: Object,
19 | * belongsTo: Relation[],
20 | * hasSome: string[],
21 | * hasMany: string[],
22 | * hasSome_property: string,
23 | * ios_push_field: string,
24 | * author_fields: string[]
25 | * }} Model
26 | */
27 |
28 | /**
29 | * @typedef {{
30 | * name: string,
31 | * keys: string[],
32 | * admins: string[],
33 | * type: "application",
34 | * id: "id",
35 | * created: 0,
36 | * modified: Number,
37 | * email_confirmation: Boolean,
38 | * from_email: string,
39 | * password_reset: Object,
40 | * password_reset.android_app_link: string,
41 | * password_reset.app_link: string,
42 | * password_reset.web_link: string,
43 | * schema: Object.builder.build() but with a few translations for ES
95 | */
96 | ElasticSearchDB.prototype.getQueryObject = function(builder) {
97 | var translationMappings = {
98 | is: 'term',
99 | not: 'not',
100 | exists: 'exists',
101 | range: 'range',
102 | in_array: 'terms',
103 | like: 'regexp'
104 | }
105 |
106 | function Translate(node) {
107 | node.children.forEach(function(child) {
108 | if (child instanceof BuilderNode) {
109 | Translate(child);
110 | } else {
111 | var replaced = Object.keys(child)[0];
112 | if (translationMappings[replaced]) {
113 | //'not' contains a filter name
114 | if (replaced == 'not') {
115 | var secondReplaced = Object.keys(child[replaced])[0];
116 |
117 | if (translationMappings[secondReplaced] !== secondReplaced) {
118 | child[replaced][translationMappings[secondReplaced]] = cloneObject(child[replaced][secondReplaced]);
119 | delete child[replaced][secondReplaced];
120 | }
121 | } else if (replaced == 'like') {
122 | child[translationMappings[replaced]] = cloneObject(child[replaced]);
123 |
124 | var fieldObj = {};
125 | Object.keys(child[translationMappings[replaced]]).forEach(function (field) {
126 | fieldObj[field] = '.*'+escapeRegExp(child[translationMappings[replaced]][field])+'.*';
127 | });
128 | child[translationMappings[replaced]] = fieldObj;
129 | delete child[replaced];
130 | } else if (translationMappings[replaced] !== replaced) {
131 | child[translationMappings[replaced]] = cloneObject(child[replaced]);
132 | delete child[replaced];
133 | }
134 | }
135 | }
136 | });
137 | };
138 |
139 | Translate(builder.root);
140 |
141 | return builder.build();
142 | };
143 |
144 | ElasticSearchDB.prototype.getObjects = function(ids, callback) {
145 | ids = ids.map(function(id) {
146 | return {_id: id};
147 | }, this);
148 |
149 | this.connection.mget({
150 | index: this.config.index,
151 | body: {
152 | docs: ids
153 | }
154 | }, function(err, results) {
155 | if (err) return callback([err]);
156 |
157 | var notFoundErrors = [];
158 | var objects = [];
159 | var versions = {};
160 |
161 | async.each(results.docs, function(result, c) {
162 | if (result.found) {
163 | objects.push(result._source);
164 | versions[result._id] = result._version;
165 | }
166 | else
167 | notFoundErrors.push(new TelepatError(TelepatError.errors.ObjectNotFound, [result._type, result._id]));
168 | c();
169 | }, function() {
170 | callback(notFoundErrors, objects, versions);
171 | });
172 | });
173 | };
174 |
175 | ElasticSearchDB.prototype.searchObjects = function(options, callback) {
176 | var reqBody = {
177 | query: {
178 | filtered: {
179 | filter: {}
180 | }
181 | }
182 | };
183 | var self = this;
184 |
185 | if (options.filters && !options.filters.isEmpty())
186 | reqBody.query.filtered.filter = this.getQueryObject(options.filters);
187 |
188 | if (options.fields) {
189 | if (!(options.scanFunction instanceof Function))
190 | return callback(new TelepatError(TelepatError.errors.ServerFailure, ['searchObjects was provided with fields but no scanFunction']));
191 |
192 | var hitsCollected = 0;
193 |
194 | this.connection.search({
195 | index: this.config.index,
196 | type: options.modelName ? options.modelName : '',
197 | body: reqBody,
198 | scroll: '10s',
199 | searchType: 'scan',
200 | fields: options.fields,
201 | size: 1024
202 | }, function getMore(err, response) {
203 | if (err) return callback(err);
204 |
205 | if (response.hits.total !== hitsCollected) {
206 | var objects = [];
207 |
208 | hitsCollected += response.hits.hits.length;
209 |
210 | async.each(response.hits.hits, function(hit, c) {
211 | var obj = {};
212 | async.forEachOf(hit.fields, function(value, f, c1) {
213 | obj[f] = value[0];
214 | c1();
215 | }, function() {
216 | objects.push(obj);
217 | c();
218 | });
219 | }, function() {
220 | if (response.hits.hits.length)
221 | options.scanFunction(objects);
222 |
223 | self.connection.scroll({
224 | scrollId: response._scroll_id,
225 | scroll: '10s'
226 | }, getMore);
227 | });
228 | } else {
229 | callback();
230 | }
231 | });
232 | } else {
233 | if (options.sort) {
234 | reqBody.sort = [];
235 |
236 | var sortFieldName = Object.keys(options.sort)[0];
237 | //old sort method
238 | if (typeof options.sort[sortFieldName] == 'string') {
239 | var sortObject = {};
240 |
241 | sortObject[sortFieldName] = {order: options.sort[sortFieldName], unmapped_type : "long"};
242 | reqBody.sort = [sortObject];
243 | } else {
244 | Object.keys(options.sort).forEach(function(field) {
245 | var sortObjectField = {};
246 |
247 | if (!options.sort[field].type) {
248 | sortObjectField[field] = {order: options.sort[field].order, unmapped_type : "long"};
249 | } else if (options.sort[field].type == 'geo') {
250 | sortObjectField._geo_distance = {};
251 | sortObjectField._geo_distance[field] = {lat: options.sort[field].poi.lat || 0.0, lon: options.sort[field].poi.long || 0.0};
252 | sortObjectField._geo_distance.order = options.sort[field].order;
253 | }
254 |
255 | reqBody.sort.push(sortObjectField);
256 | });
257 | }
258 | }
259 |
260 | this.connection.search({
261 | index: this.config.index,
262 | type: options.modelName,
263 | body: reqBody,
264 | from: options.offset,
265 | size: options.limit
266 | }, function(err, results) {
267 | if (err) return callback(err);
268 |
269 | var objects = [];
270 |
271 | results.hits.hits.forEach(function(object) {
272 | objects.push(object._source);
273 | })
274 |
275 | callback(null, objects);
276 | });
277 | }
278 | };
279 |
280 | ElasticSearchDB.prototype.countObjects = function(options, callback) {
281 | var reqBody = {
282 | query: {
283 | filtered: {
284 | filter: {}
285 | }
286 | }
287 | };
288 |
289 | if (options.filters && !options.filters.isEmpty())
290 | reqBody.query.filtered.filter = this.getQueryObject(options.filters);
291 |
292 | if (options.aggregation) {
293 | reqBody.aggs = {aggregation: options.aggregation};
294 |
295 | this.connection.search({
296 | index: this.config.index,
297 | type: options.modelName,
298 | body: reqBody,
299 | search_type: 'count',
300 | queryCache: true
301 | }, function(err, results) {
302 | if (err) return callback(err);
303 |
304 | var countResult = {count: result.hits.total};
305 |
306 | countResult.aggregation = result.aggregations.aggregation.value;
307 |
308 | callback(null, countResult);
309 | });
310 | } else {
311 | this.connection.count({
312 | index: this.config.index,
313 | type: options.modelName,
314 | body: reqBody
315 | }, function(err, result) {
316 | if (err) return callback(err);
317 |
318 | var countResult = {count: result.count};
319 |
320 | callback(null, countResult);
321 | });
322 | }
323 | };
324 |
325 | ElasticSearchDB.prototype.createObjects = function(objects, callback) {
326 | var bulk = [];
327 | var builtinModels = ['application', 'admin', 'user', 'user_metadata', 'context'];
328 | var builtinDetected = false;
329 |
330 | objects.forEach(function(obj) {
331 | var modelName = obj.type;
332 | if (builtinModels.indexOf(modelName) !== -1)
333 | builtinDetected = true;
334 |
335 | bulk.push({index: {_type: modelName, _id: obj.id}});
336 | bulk.push(obj);
337 | }, this);
338 |
339 | this.connection.bulk({
340 | index: this.config.index,
341 | body: bulk,
342 | refresh: builtinDetected
343 | }, function(err, res) {
344 | if (res.errors) {
345 | res.items.forEach(function(error) {
346 | Application.logger.error('Error creating '+error.index._type+': '+error.index.error);
347 | });
348 | }
349 |
350 | callback(err, res);
351 | });
352 | };
353 |
354 | ElasticSearchDB.prototype.updateObjects = function(patches, callback) {
355 | var ids = {};
356 | var dbObjects = {};
357 | var totalErrors = [];
358 | var builtinModels = ['application', 'admin', 'user', 'user_metadata', 'context'];
359 | var builtinDetected = false;
360 | var self = this;
361 |
362 | patches.forEach(function(patch) {
363 | var id = patch.path.split('/')[1];
364 | if (!ids[id])
365 | ids[id] = [patch];
366 | else
367 | ids[id].push(patch);
368 | });
369 |
370 | function getAndUpdate(objectIds, callback2) {
371 | var dbObjectVersions = {};
372 | var conflictedObjectIds = {};
373 | var bulk = [];
374 |
375 | async.series([
376 | function getObjects(callback1) {
377 | self.getObjects(Object.keys(objectIds), function(err, results, versions) {
378 | if (err && err.length == 1) return callback1(err[0]);
379 |
380 | totalErrors = err;
381 | results.forEach(function(object) {
382 | dbObjects[object.id] = object;
383 | dbObjectVersions[object.id] = versions[object.id];
384 | });
385 | callback1();
386 | });
387 | },
388 | function updateBulk(callback1) {
389 | async.forEachOf(objectIds, function(patches, id, c) {
390 | var objectModel = null;
391 |
392 | objectModel = patches[0].path.split('/')[0];
393 |
394 | if (builtinModels.indexOf(objectModel) !== -1)
395 | builtinDetected = true;
396 |
397 | dbObjects[id] = Delta.processObject(patches, dbObjects[id]);
398 |
399 | var script = 'def jsonSlurper = new groovy.json.JsonSlurper();'+
400 | 'def parsed = jsonSlurper.parseText(\''+JSON.stringify(dbObjects[id]).replace(/'/g, "\\'").replace(/"/g, "\\\"")+'\');'+
401 | 'ctx._source = parsed;';
402 |
403 | bulk.push({update: {_type: objectModel, _id: id, _version: dbObjectVersions[id]}});
404 | bulk.push({script: script});
405 | c();
406 | }, function() {
407 | self.connection.bulk({
408 | index: self.config.index,
409 | body: bulk,
410 | refresh: builtinDetected
411 | }, function(err, res) {
412 | if (err)
413 | return callback1(err);
414 |
415 | var temporaryErrors = [];
416 | if (res.errors)
417 | res.items.forEach(function(error) {
418 | if (error.update.status === 409)
419 | conflictedObjectIds[error.update._id] = ids[error.update._id];
420 | else
421 | totalErrors.push(new Error('Failed to update '+error.update._type+' with ID '+error.update._id+': '+error.update.error));
422 | });
423 |
424 | callback1();
425 | });
426 | });
427 | }
428 | ], function(err) {
429 | if (err)
430 | return callback2(err);
431 |
432 | if (Object.keys(conflictedObjectIds).length)
433 | return getAndUpdate(conflictedObjectIds, callback2);
434 |
435 | callback2();
436 | });
437 | }
438 |
439 | getAndUpdate(ids, function(err) {
440 | if (err)
441 | callback([err]);
442 | else
443 | callback(totalErrors, dbObjects);
444 | });
445 | };
446 |
447 | ElasticSearchDB.prototype.deleteObjects = function(ids, callback) {
448 | var self = this;
449 | var bulk = [];
450 | var builtinModels = ['application', 'admin', 'user', 'user_metadata', 'context'];
451 | var builtinDetected = false;
452 |
453 | async.each(Object.keys(ids), function(id, c) {
454 | if (builtinModels.indexOf(ids[id]) !== -1)
455 | builtinDetected = true;
456 |
457 | bulk.push({delete: {_type: ids[id], _id: id}});
458 | c();
459 | }, function(err) {
460 | self.connection.bulk({
461 | index: self.config.index,
462 | body: bulk,
463 | refresh: builtinDetected
464 | }, function(err, results) {
465 | if (err) return callback([err]);
466 | var notFoundErrors = [];
467 |
468 | async.each(results.docs, function(result, c) {
469 | if (!result.found)
470 | notFoundErrors.push(new TelepatError(TelepatError.errors.ObjectNotFound, [result._type, result._id]));
471 | c();
472 | }, function() {
473 | callback(notFoundErrors.length ? notFoundErrors : null);
474 | });
475 | });
476 | });
477 | };
478 |
479 | module.exports = ElasticSearchDB;
480 |
--------------------------------------------------------------------------------