diff --git a/Gruntfile.js b/Gruntfile.js index 885f909c..cfd7b873 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -103,11 +103,7 @@ module.exports = function(grunt) { // - PhantomJS // - IE (only Windows) browsers: [ - 'Chrome', - 'Firefox', - 'Opera', - 'Safari', - 'PhantomJS' + 'Chrome' ], // If browser does not capture in given timeout [ms], kill it @@ -136,6 +132,83 @@ module.exports = function(grunt) { // Add browserify to preprocessors preprocessors: {'test/*': ['browserify']} } + }, + e2e: { + options: { + // base path, that will be used to resolve files and exclude + basePath: '', + + // frameworks to use + frameworks: ['mocha', 'browserify'], + + // list of files / patterns to load in the browser + files: [ + 'test/e2e/remote-connector.e2e.js' + ], + + // list of files to exclude + exclude: [ + + ], + + // test results reporter to use + // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' + reporters: ['dots'], + + // web server port + port: 9876, + + // cli runner port + runnerPort: 9100, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: 'warn', + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + browsers: [ + 'Chrome' + ], + + // If browser does not capture in given timeout [ms], kill it + captureTimeout: 60000, + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: false, + + // Browserify config (all optional) + browserify: { + // extensions: ['.coffee'], + ignore: [ + 'nodemailer', + 'passport', + 'passport-local', + 'superagent', + 'supertest' + ], + // transform: ['coffeeify'], + // debug: true, + // noParse: ['jquery'], + watch: true, + }, + + // Add browserify to preprocessors + preprocessors: {'test/e2e/*': ['browserify']} + } } } @@ -148,6 +221,14 @@ module.exports = function(grunt) { grunt.loadNpmTasks('grunt-contrib-watch'); grunt.loadNpmTasks('grunt-karma'); + grunt.registerTask('e2e-server', function() { + var done = this.async(); + var app = require('./test/fixtures/e2e/app'); + app.listen(3000, done); + }); + + grunt.registerTask('e2e', ['e2e-server', 'karma:e2e']); + // Default task. grunt.registerTask('default', ['browserify']); diff --git a/example/client-server/client.js b/example/client-server/client.js new file mode 100644 index 00000000..4e1e423c --- /dev/null +++ b/example/client-server/client.js @@ -0,0 +1,20 @@ +var loopback = require('../../'); +var client = loopback(); +var CartItem = require('./models').CartItem; +var remote = loopback.createDataSource({ + connector: loopback.Remote, + root: 'http://localhost:3000' +}); + +client.model(CartItem); +CartItem.attachTo(remote); + +// call the remote method +CartItem.sum(1, function(err, total) { + console.log('result:', err || total); +}); + +// call a built in remote method +CartItem.find(function(err, items) { + console.log(items); +}); diff --git a/example/client-server/models.js b/example/client-server/models.js new file mode 100644 index 00000000..c14485c8 --- /dev/null +++ b/example/client-server/models.js @@ -0,0 +1,35 @@ +var loopback = require('../../'); + +var CartItem = exports.CartItem = loopback.DataModel.extend('CartItem', { + tax: {type: Number, default: 0.1}, + price: Number, + item: String, + qty: {type: Number, default: 0}, + cartId: Number +}); + +CartItem.sum = function(cartId, callback) { + this.find({where: {cartId: 1}}, function(err, items) { + var total = items + .map(function(item) { + return item.total(); + }) + .reduce(function(cur, prev) { + return prev + cur; + }, 0); + + callback(null, total); + }); +} + +loopback.remoteMethod( + CartItem.sum, + { + accepts: {arg: 'cartId', type: 'number'}, + returns: {arg: 'total', type: 'number'} + } +); + +CartItem.prototype.total = function() { + return this.price * this.qty * 1 + this.tax; +} diff --git a/example/client-server/server.js b/example/client-server/server.js new file mode 100644 index 00000000..7e466a56 --- /dev/null +++ b/example/client-server/server.js @@ -0,0 +1,24 @@ +var loopback = require('../../'); +var server = module.exports = loopback(); +var CartItem = require('./models').CartItem; +var memory = loopback.createDataSource({ + connector: loopback.Memory +}); + +server.use(loopback.rest()); +server.model(CartItem); + +CartItem.attachTo(memory); + +// test data +CartItem.create([ + {item: 'red hat', qty: 6, price: 19.99, cartId: 1}, + {item: 'green shirt', qty: 1, price: 14.99, cartId: 1}, + {item: 'orange pants', qty: 58, price: 9.99, cartId: 1} +]); + +CartItem.sum(1, function(err, total) { + console.log(total); +}); + +server.listen(3000); diff --git a/index.js b/index.js index d9099a70..739fa4f5 100644 --- a/index.js +++ b/index.js @@ -12,6 +12,7 @@ var datasourceJuggler = require('loopback-datasource-juggler'); loopback.Connector = require('./lib/connectors/base-connector'); loopback.Memory = require('./lib/connectors/memory'); loopback.Mail = require('./lib/connectors/mail'); +loopback.Remote = require('./lib/connectors/remote'); /** * Types diff --git a/lib/application.js b/lib/application.js index 54d509ef..6e71ce7c 100644 --- a/lib/application.js +++ b/lib/application.js @@ -56,7 +56,12 @@ app.remotes = function () { if(this._remotes) { return this._remotes; } else { - var options = this.get('remoting') || {}; + var options = {}; + + if(this.get) { + options = this.get('remoting'); + } + return (this._remotes = RemoteObjects.create(options)); } } diff --git a/lib/connectors/remote.js b/lib/connectors/remote.js new file mode 100644 index 00000000..065682f6 --- /dev/null +++ b/lib/connectors/remote.js @@ -0,0 +1,68 @@ +/** + * Dependencies. + */ + +var assert = require('assert') + , compat = require('../compat') + , _ = require('underscore'); + +/** + * Export the RemoteConnector class. + */ + +module.exports = RemoteConnector; + +/** + * Create an instance of the connector with the given `settings`. + */ + +function RemoteConnector(settings) { + assert(typeof settings === 'object', 'cannot initiaze RemoteConnector without a settings object'); + this.client = settings.client; + this.adapter = settings.adapter || 'rest'; + this.protocol = settings.protocol || 'http' + this.root = settings.root || ''; + this.host = settings.host || 'localhost'; + this.port = settings.port || 3000; + + if(settings.url) { + this.url = settings.url; + } else { + this.url = this.protocol + '://' + this.host + ':' + this.port + this.root; + } + + // handle mixins here + this.DataAccessObject = function() {}; +} + +RemoteConnector.prototype.connect = function() { +} + + +RemoteConnector.initialize = function(dataSource, callback) { + var connector = dataSource.connector = new RemoteConnector(dataSource.settings); + connector.connect(); + callback(); +} + +RemoteConnector.prototype.define = function(definition) { + var Model = definition.model; + var className = compat.getClassNameForRemoting(Model); + var url = this.url; + var adapter = this.adapter; + + Model.remotes(function(err, remotes) { + var sharedClass = getSharedClass(remotes, className); + remotes.connect(url, adapter); + sharedClass + .methods() + .forEach(Model.createProxyMethod.bind(Model)); + }); +} + +function getSharedClass(remotes, className) { + return _.find(remotes.classes(), function(sharedClass) { + return sharedClass.name === className; + }); +} +function noop() {} diff --git a/lib/loopback.js b/lib/loopback.js index be705340..95d297ba 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -311,6 +311,7 @@ loopback.autoAttachModel = function(ModelCtor) { */ loopback.Model = require('./models/model'); +loopback.DataModel = require('./models/data-model'); loopback.Email = require('./models/email'); loopback.User = require('./models/user'); loopback.Application = require('./models/application'); @@ -330,6 +331,7 @@ var dataSourceTypes = { }; loopback.Email.autoAttach = dataSourceTypes.MAIL; +loopback.DataModel.autoAttach = dataSourceTypes.DB; loopback.User.autoAttach = dataSourceTypes.DB; loopback.AccessToken.autoAttach = dataSourceTypes.DB; loopback.Role.autoAttach = dataSourceTypes.DB; diff --git a/lib/models/data-model.js b/lib/models/data-model.js new file mode 100644 index 00000000..bb8c60cf --- /dev/null +++ b/lib/models/data-model.js @@ -0,0 +1,331 @@ +/*! + * Module Dependencies. + */ +var Model = require('./model'); + +/** + * Extends Model with basic query and CRUD support. + * + * @class DataModel + * @param {Object} data + */ + +var DataModel = module.exports = Model.extend('DataModel'); + +/*! + * Configure the remoting attributes for a given function + * @param {Function} fn The function + * @param {Object} options The options + * @private + */ + +function setRemoting(fn, options) { + options = options || {}; + for (var opt in options) { + if (options.hasOwnProperty(opt)) { + fn[opt] = options[opt]; + } + } + fn.shared = true; + // allow connectors to override the function by marking as delegate + fn._delegate = true; +} + +/*! + * Throw an error telling the user that the method is not available and why. + */ + +function throwNotAttached(modelName, methodName) { + throw new Error( + 'Cannot call ' + modelName + '.'+ methodName + '().' + + ' The ' + methodName + ' method has not been setup.' + + ' The DataModel has not been correctly attached to a DataSource!' + ); +} + +/*! + * Convert null callbacks to 404 error objects. + * @param {HttpContext} ctx + * @param {Function} cb + */ + +function convertNullToNotFoundError(ctx, cb) { + if (ctx.result !== null) return cb(); + + var modelName = ctx.method.sharedClass.name; + var id = ctx.getArgByName('id'); + var msg = 'Unkown "' + modelName + '" id "' + id + '".'; + var error = new Error(msg); + error.statusCode = error.status = 404; + cb(error); +} + +/** + * Create new instance of Model class, saved in database + * + * @param data [optional] + * @param callback(err, obj) + * callback called with arguments: + * + * - err (null or Error) + * - instance (null or Model) + */ + +DataModel.create = function (data, callback) { + throwNotAttached(this.modelName, 'create'); +}; + +setRemoting(DataModel.create, { + description: 'Create a new instance of the model and persist it into the data source', + accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, + returns: {arg: 'data', type: 'object', root: true}, + http: {verb: 'post', path: '/'} +}); + +/** + * Update or insert a model instance + * @param {Object} data The model instance data + * @param {Function} [callback] The callback function + */ + +DataModel.upsert = DataModel.updateOrCreate = function upsert(data, callback) { + throwNotAttached(this.modelName, 'updateOrCreate'); +}; + +// upsert ~ remoting attributes +setRemoting(DataModel.upsert, { + description: 'Update an existing model instance or insert a new one into the data source', + accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, + returns: {arg: 'data', type: 'object', root: true}, + http: {verb: 'put', path: '/'} +}); + +/** + * Find one record, same as `all`, limited by 1 and return object, not collection, + * if not found, create using data provided as second argument + * + * @param {Object} query - search conditions: {where: {test: 'me'}}. + * @param {Object} data - object to create. + * @param {Function} cb - callback called with (err, instance) + */ + +DataModel.findOrCreate = function findOrCreate(query, data, callback) { + throwNotAttached(this.modelName, 'findOrCreate'); +}; + +/** + * Check whether a model instance exists in database + * + * @param {id} id - identifier of object (primary key value) + * @param {Function} cb - callbacl called with (err, exists: Bool) + */ + +DataModel.exists = function exists(id, cb) { + throwNotAttached(this.modelName, 'exists'); +}; + +// exists ~ remoting attributes +setRemoting(DataModel.exists, { + description: 'Check whether a model instance exists in the data source', + accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, + returns: {arg: 'exists', type: 'any'}, + http: {verb: 'get', path: '/:id/exists'} +}); + +/** + * Find object by id + * + * @param {*} id - primary key value + * @param {Function} cb - callback called with (err, instance) + */ + +DataModel.findById = function find(id, cb) { + throwNotAttached(this.modelName, 'find'); +}; + +// find ~ remoting attributes +setRemoting(DataModel.findById, { + description: 'Find a model instance by id from the data source', + accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, + returns: {arg: 'data', type: 'any', root: true}, + http: {verb: 'get', path: '/:id'}, + rest: {after: convertNullToNotFoundError} +}); + +/** + * Find all instances of Model, matched by query + * make sure you have marked as `index: true` fields for filter or sort + * + * @param {Object} params (optional) + * + * - where: Object `{ key: val, key2: {gt: 'val2'}}` + * - include: String, Object or Array. See DataModel.include documentation. + * - order: String + * - limit: Number + * - skip: Number + * + * @param {Function} callback (required) called with arguments: + * + * - err (null or Error) + * - Array of instances + */ + +DataModel.find = function find(params, cb) { + throwNotAttached(this.modelName, 'find'); +}; + +// all ~ remoting attributes +setRemoting(DataModel.find, { + description: 'Find all instances of the model matched by filter from the data source', + accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, + returns: {arg: 'data', type: 'array', root: true}, + http: {verb: 'get', path: '/'} +}); + +/** + * Find one record, same as `all`, limited by 1 and return object, not collection + * + * @param {Object} params - search conditions: {where: {test: 'me'}} + * @param {Function} cb - callback called with (err, instance) + */ + +DataModel.findOne = function findOne(params, cb) { + throwNotAttached(this.modelName, 'findOne'); +}; + +setRemoting(DataModel.findOne, { + description: 'Find first instance of the model matched by filter from the data source', + accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, + returns: {arg: 'data', type: 'object', root: true}, + http: {verb: 'get', path: '/findOne'} +}); + +/** + * Destroy all matching records + * @param {Object} [where] An object that defines the criteria + * @param {Function} [cb] - callback called with (err) + */ + +DataModel.remove = +DataModel.deleteAll = +DataModel.destroyAll = function destroyAll(where, cb) { + throwNotAttached(this.modelName, 'destroyAll'); +}; + +/** + * Destroy a record by id + * @param {*} id The id value + * @param {Function} cb - callback called with (err) + */ + +DataModel.removeById = +DataModel.deleteById = +DataModel.destroyById = function deleteById(id, cb) { + throwNotAttached(this.modelName, 'deleteById'); +}; + +// deleteById ~ remoting attributes +setRemoting(DataModel.deleteById, { + description: 'Delete a model instance by id from the data source', + accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, + http: {verb: 'del', path: '/:id'} +}); + +/** + * Return count of matched records + * + * @param {Object} where - search conditions (optional) + * @param {Function} cb - callback, called with (err, count) + */ + +DataModel.count = function (where, cb) { + throwNotAttached(this.modelName, 'count'); +}; + +// count ~ remoting attributes +setRemoting(DataModel.count, { + description: 'Count instances of the model matched by where from the data source', + accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, + returns: {arg: 'count', type: 'number'}, + http: {verb: 'get', path: '/count'} +}); + +/** + * Save instance. When instance haven't id, create method called instead. + * Triggers: validate, save, update | create + * @param options {validate: true, throws: false} [optional] + * @param callback(err, obj) + */ + +DataModel.prototype.save = function (options, callback) { + throwNotAttached(this.constructor.modelName, 'save'); +}; + + +/** + * Determine if the data model is new. + * @returns {Boolean} + */ + +DataModel.prototype.isNewRecord = function () { + throwNotAttached(this.constructor.modelName, 'isNewRecord'); +}; + +/** + * Delete object from persistence + * + * @triggers `destroy` hook (async) before and after destroying object + */ + +DataModel.prototype.remove = +DataModel.prototype.delete = +DataModel.prototype.destroy = function (cb) { + throwNotAttached(this.constructor.modelName, 'destroy'); +}; + +/** + * Update single attribute + * + * equals to `updateAttributes({name: value}, cb) + * + * @param {String} name - name of property + * @param {Mixed} value - value of property + * @param {Function} callback - callback called with (err, instance) + */ + +DataModel.prototype.updateAttribute = function updateAttribute(name, value, callback) { + throwNotAttached(this.constructor.modelName, 'updateAttribute'); +}; + +/** + * Update set of attributes + * + * this method performs validation before updating + * + * @trigger `validation`, `save` and `update` hooks + * @param {Object} data - data to update + * @param {Function} callback - callback called with (err, instance) + */ + +DataModel.prototype.updateAttributes = function updateAttributes(data, cb) { + throwNotAttached(this.modelName, 'updateAttributes'); +}; + +// updateAttributes ~ remoting attributes +setRemoting(DataModel.prototype.updateAttributes, { + description: 'Update attributes for a model instance and persist it into the data source', + accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, + returns: {arg: 'data', type: 'object', root: true}, + http: {verb: 'put', path: '/'} +}); + +/** + * Reload object from persistence + * + * @requires `id` member of `object` to be able to call `find` + * @param {Function} callback - called with (err, instance) arguments + */ + +DataModel.prototype.reload = function reload(callback) { + throwNotAttached(this.constructor.modelName, 'reload'); +}; diff --git a/lib/models/model.js b/lib/models/model.js index fdc48f55..742c3438 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -201,6 +201,72 @@ Model._getAccessTypeForMethod = function(method) { } } +/** + * Get the `Application` the Model is attached to. + * + * @callback {Function} callback + * @param {Error} err + * @param {Application} app + * @end + */ + +Model.getApp = function(callback) { + var Model = this; + if(this.app) { + callback(null, this.app); + } else { + Model.once('attached', function() { + assert(Model.app); + callback(null, Model.app); + }); + } +} + +/** + * Get the Model's `RemoteObjects`. + * + * @callback {Function} callback + * @param {Error} err + * @param {RemoteObjects} remoteObjects + * @end + */ + +Model.remotes = function(callback) { + this.getApp(function(err, app) { + callback(null, app.remotes()); + }); +} + +/*! + * Create a proxy function for invoking remote methods. + * + * @param {SharedMethod} sharedMethod + */ + +Model.createProxyMethod = function createProxyFunction(remoteMethod) { + var Model = this; + var scope = remoteMethod.isStatic ? Model : Model.prototype; + var original = scope[remoteMethod.name]; + + var fn = scope[remoteMethod.name] = function proxy() { + var args = Array.prototype.slice.call(arguments); + var lastArgIsFunc = typeof args[args.length - 1] === 'function'; + var callback; + if(lastArgIsFunc) { + callback = args.pop(); + } + + Model.remotes(function(err, remotes) { + remotes.invoke(remoteMethod.stringName, args, callback); + }); + } + + for(var key in original) { + fn[key] = original[key]; + } + fn._delegate = true; +} + // setup the initial model Model.setup(); diff --git a/package.json b/package.json index ce87377d..9b7c26d8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dependencies": { "debug": "~0.7.4", "express": "~3.4.8", - "strong-remoting": "~1.2.6", + "strong-remoting": "~1.3.1", "inflection": "~1.3.5", "passport": "~0.2.0", "passport-local": "~0.1.6", @@ -38,7 +38,7 @@ "supertest": "~0.9.0", "chai": "~1.9.0", "loopback-testing": "~0.1.2", - "browserify": "~3.30.2", + "browserify": "~3.41.0", "grunt": "~0.4.2", "grunt-browserify": "~1.3.1", "grunt-contrib-uglify": "~0.3.2", @@ -50,7 +50,7 @@ "karma-html2js-preprocessor": "~0.1.0", "karma-phantomjs-launcher": "~0.1.2", "karma": "~0.10.9", - "karma-browserify": "0.1.0", + "karma-browserify": "~0.2.0", "karma-mocha": "~0.1.1", "grunt-karma": "~0.6.2" }, @@ -62,7 +62,8 @@ "express": "./lib/browser-express.js", "connect": false, "passport": false, - "passport-local": false + "passport-local": false, + "nodemailer": false }, "license": { "name": "Dual MIT/StrongLoop", diff --git a/test/e2e/remote-connector.e2e.js b/test/e2e/remote-connector.e2e.js new file mode 100644 index 00000000..c1258056 --- /dev/null +++ b/test/e2e/remote-connector.e2e.js @@ -0,0 +1,28 @@ +var path = require('path'); +var loopback = require('../../'); +var models = require('../fixtures/e2e/models'); +var TestModel = models.TestModel; +var assert = require('assert'); + +describe('RemoteConnector', function() { + before(function() { + // setup the remote connector + var localApp = loopback(); + var ds = loopback.createDataSource({ + url: 'http://localhost:3000/api', + connector: loopback.Remote + }); + localApp.model(TestModel); + TestModel.attachTo(ds); + }); + + it('should be able to call create', function (done) { + TestModel.create({ + foo: 'bar' + }, function(err, inst) { + if(err) return done(err); + assert(inst.id); + done(); + }); + }); +}); diff --git a/test/fixtures/e2e/app.js b/test/fixtures/e2e/app.js new file mode 100644 index 00000000..337d6145 --- /dev/null +++ b/test/fixtures/e2e/app.js @@ -0,0 +1,14 @@ +var loopback = require('../../../'); +var path = require('path'); +var app = module.exports = loopback(); +var models = require('./models'); +var TestModel = models.TestModel; + +app.use(loopback.cookieParser({secret: app.get('cookieSecret')})); +var apiPath = '/api'; +app.use(apiPath, loopback.rest()); +app.use(loopback.static(path.join(__dirname, 'public'))); +app.use(loopback.urlNotFound()); +app.use(loopback.errorHandler()); +app.model(TestModel); +TestModel.attachTo(loopback.memory()); diff --git a/test/fixtures/e2e/models.js b/test/fixtures/e2e/models.js new file mode 100644 index 00000000..dad14f61 --- /dev/null +++ b/test/fixtures/e2e/models.js @@ -0,0 +1,4 @@ +var loopback = require('../../../'); +var DataModel = loopback.DataModel; + +exports.TestModel = DataModel.extend('TestModel'); diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js new file mode 100644 index 00000000..de996b92 --- /dev/null +++ b/test/remote-connector.test.js @@ -0,0 +1,33 @@ +var loopback = require('../'); + +describe('RemoteConnector', function() { + beforeEach(function(done) { + var LocalModel = this.LocalModel = loopback.DataModel.extend('LocalModel'); + var RemoteModel = loopback.DataModel.extend('LocalModel'); + var localApp = loopback(); + var remoteApp = loopback(); + localApp.model(LocalModel); + remoteApp.model(RemoteModel); + remoteApp.use(loopback.rest()); + RemoteModel.attachTo(loopback.memory()); + remoteApp.listen(0, function() { + var ds = loopback.createDataSource({ + host: remoteApp.get('host'), + port: remoteApp.get('port'), + connector: loopback.Remote + }); + + LocalModel.attachTo(ds); + done(); + }); + }); + + it('should alow methods to be called remotely', function (done) { + var data = {foo: 'bar'}; + this.LocalModel.create(data, function(err, result) { + if(err) return done(err); + expect(result).to.deep.equal({id: 1, foo: 'bar'}); + done(); + }); + }); +});