diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3ee22e5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..962ec7c --- /dev/null +++ b/.eslintrc @@ -0,0 +1,134 @@ +{ + "env": { + "browser": false, + "node": true, + "amd": true + }, + + "rules": { + "no-alert": 2, + "no-array-constructor": 2, + "no-bitwise": 2, + "no-caller": 2, + "no-catch-shadow": 2, + "no-comma-dangle": 2, + "no-cond-assign": 2, + "no-console": 1, + "no-constant-condition": 2, + "no-control-regex": 2, + "no-debugger": 2, + "no-delete-var": 2, + "no-div-regex": 1, + "no-dupe-keys": 2, + "no-else-return": 2, + "no-empty": 2, + "no-empty-class": 2, + "no-empty-label": 2, + "no-eq-null": 2, + "no-eval": 2, + "no-ex-assign": 2, + "no-extend-native": 2, + "no-extra-boolean-cast": 2, + "no-extra-parens": 1, + "no-extra-semi": 2, + "no-extra-strict": 2, + "no-fallthrough": 2, + "no-floating-decimal": 1, + "no-func-assign": 2, + "no-global-strict": 2, + "no-implied-eval": 2, + "no-inner-declarations": [2, "functions"], + "no-invalid-regexp": 2, + "no-iterator": 2, + "no-label-var": 2, + "no-labels": 2, + "no-lone-blocks": 2, + "no-lonely-if": 2, + "no-loop-func": 2, + "no-mixed-requires": [2, false], + "no-multi-str": 2, + "no-native-reassign": 2, + "no-negated-in-lhs": 2, + "no-nested-ternary": 1, + "no-new": 2, + "no-new-func": 2, + "no-new-object": 2, + "no-new-require": 1, + "no-new-wrappers": 2, + "no-obj-calls": 2, + "no-octal": 2, + "no-octal-escape": 2, + "no-path-concat": 0, + "no-plusplus": 2, + "no-process-exit": 2, + "no-proto": 2, + "no-redeclare": 2, + "no-regex-spaces": 2, + "no-restricted-modules": 0, + "no-return-assign": 2, + "no-script-url": 2, + "no-self-compare": 0, + "no-sequences": 2, + "no-shadow": 2, + "no-shadow-restricted-names": 2, + "no-spaced-func": 2, + "no-space-before-semi": 2, + "no-sparse-arrays": 2, + "no-sync": 0, + "no-ternary": 2, + "no-trailing-spaces": 2, + "no-undef": 2, + "no-undefined": 1, + "no-undef-init": 2, + "no-underscore-dangle": 0, + "no-unreachable": 2, + "no-unused-expressions": 2, + "no-unused-vars": [2, {"vars": "all", "args": "after-used"}], + "no-use-before-define": 2, + "no-warning-comments": [1, { "terms": ["todo", "fixme", "xxx"], "location": "start" }], + "no-with": 2, + "no-wrap-func": 2, + "no-mixed-spaces-and-tabs": [2, false], + + "block-scoped-var": 1, + "brace-style": [2, "1tbs"], + "camelcase": 2, + "complexity": [0, 11], + "consistent-return": 2, + "consistent-this": [2, "that"], + "curly": [2, "all"], + "default-case": 1, + "dot-notation": 2, + "eol-last": 2, + "eqeqeq": 2, + "func-names": 0, + "func-style": [2, "declaration"], + "guard-for-in": 1, + "max-depth": [0, 4], + "max-len": [1, 80, 4], + "max-nested-callbacks": [1, 2], + "max-params": [0, 3], + "max-statements": [0, 10], + "handle-callback-err": 1, + "new-cap": 2, + "new-parens": 2, + "one-var": 0, + "quote-props": 1, + "quotes": [2, "single"], + "radix": 2, + "semi": 2, + "sort-vars": 1, + "space-after-keywords": [2, "always"], + "space-in-brackets": [1, "never"], + "space-infix-ops": 2, + "space-return-throw-case": 2, + "space-unary-word-ops": 1, + "strict": 0, + "use-isnan": 2, + "valid-jsdoc": 2, + "valid-typeof": 2, + "wrap-iife": 1, + "wrap-regex": 1, + "yoda": [2, "never"] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57befb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.idea +.project +*.sublime-* +.DS_Store +*.seed +*.log +*.csv +*.dat +*.out +*.pid +*.swp +*.swo +node_modules +coverage +*.tgz +*.xml +*._* diff --git a/.jshintignore b/.jshintignore new file mode 100644 index 0000000..2ccbe46 --- /dev/null +++ b/.jshintignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..17430da --- /dev/null +++ b/.jshintrc @@ -0,0 +1,21 @@ +{ + "node": true, + "esnext": true, + "bitwise": true, + "camelcase": true, + "eqeqeq": true, + "eqnull": true, + "immed": true, + "indent": 2, + "latedef": "nofunc", + "newcap": true, + "nonew": true, + "noarg": true, + "quotmark": "single", + "regexp": true, + "undef": true, + "unused": true, + "trailing": true, + "sub": true, + "maxlen": 80 +} diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..7ec7473 --- /dev/null +++ b/.npmignore @@ -0,0 +1,16 @@ +.idea +.project +*.sublime-* +.DS_Store +*.seed +*.log +*.csv +*.dat +*.out +*.pid +*.swp +*.swo +node_modules +coverage +*.tgz +*.xml diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..5697bf3 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,50 @@ +/*global module:false*/ +module.exports = function(grunt) { + // Project configuration. + grunt.initConfig({ + // Metadata. + pkg: grunt.file.readJSON('package.json'), + banner: '/*! <%= pkg.title || pkg.name %> - v<%= pkg.version %> - ' + + '<%= grunt.template.today("yyyy-mm-dd") %>\n' + + '<%= pkg.homepage ? "* " + pkg.homepage + "\\n" : "" %>' + + '* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' + + ' Licensed <%= _.pluck(pkg.licenses, "type").join(", ") %> */\n', + // Task configuration. + jshint: { + options: { + jshintrc: true + }, + gruntfile: { + src: 'Gruntfile.js' + }, + lib_test: { + src: ['lib/**/*.js', 'test/**/*.js'] + } + }, + mochaTest: { + 'unit': { + src: 'test/*.js', + options: { + reporter: 'dot' + } + }, + 'unit-xml': { + src: 'test/*.js', + options: { + reporter: 'xunit', + captureFile: 'xunit.xml' + } + } + } + }); + + // These plugins provide necessary tasks. + grunt.loadNpmTasks('grunt-mocha-test'); + grunt.loadNpmTasks('grunt-contrib-jshint'); + + // Default task. + grunt.registerTask('default', ['test']); + + grunt.registerTask('test', [ + process.env.JENKINS_HOME ? 'mochaTest:unit-xml' : 'mochaTest:unit']); +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..191d30b --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/remote-connector'); \ No newline at end of file diff --git a/lib/remote-connector.js b/lib/remote-connector.js new file mode 100644 index 0000000..26beb4a --- /dev/null +++ b/lib/remote-connector.js @@ -0,0 +1,90 @@ +/** + * Dependencies. + */ + +var assert = require('assert'); +var remoting = require('strong-remoting'); +var DataAccessObject = require('loopback-datasource-juggler/lib/dao'); + +/** + * 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; + this.remotes = remoting.create(); + + // TODO(ritch) make sure this name works with Model.getSourceId() + this.name = 'remote-connector'; + + if(settings.url) { + this.url = settings.url; + } else { + this.url = this.protocol + '://' + this.host + ':' + this.port + this.root; + } + + // handle mixins in the define() method + var DAO = this.DataAccessObject = function() {}; +} + +RemoteConnector.prototype.connect = function() { + this.remotes.connect(this.url, this.adapter); +} + +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 remotes = this.remotes; + var SharedClass; + + assert(Model.sharedClass, 'cannot attach ' + Model.modelName + + ' to a remote connector without a Model.sharedClass'); + + remotes.addClass(Model.sharedClass); + + Model + .sharedClass + .methods() + .forEach(function(remoteMethod) { + // TODO(ritch) more elegant way of ignoring a nested shared class + if(remoteMethod.name !== 'Change' + && remoteMethod.name !== 'Checkpoint') { + createProxyMethod(Model, remotes, remoteMethod); + } + }); +} + +function createProxyMethod(Model, remotes, remoteMethod) { + var scope = remoteMethod.isStatic ? Model : Model.prototype; + var original = scope[remoteMethod.name]; + + scope[remoteMethod.name] = function remoteMethodProxy() { + var args = Array.prototype.slice.call(arguments); + var lastArgIsFunc = typeof args[args.length - 1] === 'function'; + var callback; + if(lastArgIsFunc) { + callback = args.pop(); + } + + remotes.invoke(remoteMethod.stringName, args, callback); + } +} + +function noop() {} diff --git a/package.json b/package.json new file mode 100644 index 0000000..938b665 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "loopback-connector-remote", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "grunt test" + }, + "repository": { + "type": "git", + "url": "https://github.com/kraman/loopback-connector-remotekr.git" + }, + "author": "Krishna Raman ", + "license": "StrongLoop", + "bugs": { + "url": "https://github.com/kraman/loopback-connector-remotekr/issues" + }, + "homepage": "https://github.com/kraman/loopback-connector-remotekr", + "dependencies": { + "loopback-datasource-juggler": "^2.8.0", + "strong-remoting": "^2.1.0" + }, + "devDependencies": { + "assert": "^1.1.2", + "async": "^0.9.0", + "grunt": "~0.4.5", + "grunt-browserify": "~3.0.1", + "grunt-cli": "^0.1.13", + "grunt-contrib-jshint": "~0.10.0", + "grunt-karma": "~0.9.0", + "grunt-mocha-test": "^0.11.0", + "karma": "~0.12.23", + "karma-junit-reporter": "^0.2.2", + "karma-mocha": "^0.1.9", + "loopback": "^2.2.0", + "loopback-datasource-juggler": "^2.9.0", + "mocha": "~1.21.4", + "strong-task-emitter": "0.0.5", + "supertest": "~0.13.0" + } +} diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js new file mode 100644 index 0000000..6c95b46 --- /dev/null +++ b/test/remote-connector.test.js @@ -0,0 +1,72 @@ +var loopback = require('loopback'); +var defineModelTestsWithDataSource = require('./util/model-tests'); +var assert = require('assert'); + +describe('RemoteConnector', function() { + var remoteApp; + var remote; + + defineModelTestsWithDataSource({ + beforeEach: function(done) { + var test = this; + remoteApp = loopback(); + remoteApp.use(loopback.rest()); + remoteApp.listen(0, function() { + test.dataSource = loopback.createDataSource({ + host: remoteApp.get('host'), + port: remoteApp.get('port'), + connector: loopback.Remote + }); + done(); + }); + }, + onDefine: function(Model) { + var RemoteModel = Model.extend(Model.modelName); + RemoteModel.attachTo(loopback.createDataSource({ + connector: loopback.Memory + })); + remoteApp.model(RemoteModel); + } + }); + + beforeEach(function(done) { + var test = this; + remoteApp = this.remoteApp = loopback(); + remoteApp.use(loopback.rest()); + var ServerModel = this.ServerModel = loopback.PersistedModel.extend('TestModel'); + + remoteApp.model(ServerModel); + + remoteApp.listen(0, function() { + test.remote = loopback.createDataSource({ + host: remoteApp.get('host'), + port: remoteApp.get('port'), + connector: loopback.Remote + }); + done(); + }); + }); + + it('should support the save method', function (done) { + var calledServerCreate = false; + var RemoteModel = loopback.PersistedModel.extend('TestModel'); + RemoteModel.attachTo(this.remote); + + var ServerModel = this.ServerModel; + + ServerModel.create = function(data, cb) { + calledServerCreate = true; + data.id = 1; + cb(null, data); + } + + ServerModel.setupRemoting(); + + var m = new RemoteModel({foo: 'bar'}); + m.save(function(err, inst) { + assert(inst instanceof RemoteModel); + assert(calledServerCreate); + done(); + }); + }); +}); diff --git a/test/util/describe.js b/test/util/describe.js new file mode 100644 index 0000000..ff34ec2 --- /dev/null +++ b/test/util/describe.js @@ -0,0 +1,19 @@ +var loopback = require('loopback'); + +module.exports = describe; + +describe.onServer = function describeOnServer(name, fn) { + if (loopback.isServer) { + describe(name, fn); + } else { + describe.skip(name, fn); + } +}; + +describe.inBrowser = function describeInBrowser(name, fn) { + if (loopback.isBrowser) { + describe(name, fn); + } else { + describe.skip(name, fn); + } +}; diff --git a/test/util/it.js b/test/util/it.js new file mode 100644 index 0000000..a2f23b7 --- /dev/null +++ b/test/util/it.js @@ -0,0 +1,19 @@ +var loopback = require('loopback'); + +module.exports = it; + +it.onServer = function itOnServer(name, fn) { + if (loopback.isServer) { + it(name, fn); + } else { + it.skip(name, fn); + } +}; + +it.inBrowser = function itInBrowser(name, fn) { + if (loopback.isBrowser) { + it(name, fn); + } else { + it.skip(name, fn); + } +}; diff --git a/test/util/model-tests.js b/test/util/model-tests.js new file mode 100644 index 0000000..16b1e31 --- /dev/null +++ b/test/util/model-tests.js @@ -0,0 +1,249 @@ +var assert = require('assert'); +var async = require('async'); +var describe = require('./describe'); +var loopback = require('loopback'); +var ACL = loopback.ACL; +var Change = loopback.Change; +var PersistedModel = loopback.PersistedModel; +var RemoteObjects = require('strong-remoting'); +var TaskEmitter = require('strong-task-emitter'); + +module.exports = function defineModelTestsWithDataSource(options) { + +describe('Model Tests', function() { + + var User, dataSource; + + if(options.beforeEach) { + beforeEach(options.beforeEach); + } + + beforeEach(function() { + var test = this; + + // setup a model / datasource + dataSource = this.dataSource || loopback.createDataSource(options.dataSource); + + var extend = PersistedModel.extend; + + // create model hook + PersistedModel.extend = function() { + var extendedModel = extend.apply(PersistedModel, arguments); + + if(options.onDefine) { + options.onDefine.call(test, extendedModel); + } + + return extendedModel; + } + + User = PersistedModel.extend('user', { + 'first': String, + 'last': String, + 'age': Number, + 'password': String, + 'gender': String, + 'domain': String, + 'email': String + }, { + trackChanges: true + }); + + // enable destroy all for testing + User.destroyAll.shared = true; + User.attachTo(dataSource); + }); + + describe('Model.validatesPresenceOf(properties...)', function() { + it("Require a model to include a property to be considered valid", function() { + User.validatesPresenceOf('first', 'last', 'age'); + var joe = new User({first: 'joe'}); + assert(joe.isValid() === false, 'model should not validate'); + assert(joe.errors.last, 'should have a missing last error'); + assert(joe.errors.age, 'should have a missing age error'); + }); + }); + + describe('Model.validatesLengthOf(property, options)', function() { + it("Require a property length to be within a specified range", function() { + User.validatesLengthOf('password', {min: 5, message: {min: 'Password is too short'}}); + var joe = new User({password: '1234'}); + assert(joe.isValid() === false, 'model should not be valid'); + assert(joe.errors.password, 'should have password error'); + }); + }); + + describe('Model.validatesInclusionOf(property, options)', function() { + it("Require a value for `property` to be in the specified array", function() { + User.validatesInclusionOf('gender', {in: ['male', 'female']}); + var foo = new User({gender: 'bar'}); + assert(foo.isValid() === false, 'model should not be valid'); + assert(foo.errors.gender, 'should have gender error'); + }); + }); + + describe('Model.validatesExclusionOf(property, options)', function() { + it("Require a value for `property` to not exist in the specified array", function() { + User.validatesExclusionOf('domain', {in: ['www', 'billing', 'admin']}); + var foo = new User({domain: 'www'}); + var bar = new User({domain: 'billing'}); + var bat = new User({domain: 'admin'}); + assert(foo.isValid() === false); + assert(bar.isValid() === false); + assert(bat.isValid() === false); + assert(foo.errors.domain, 'model should have a domain error'); + assert(bat.errors.domain, 'model should have a domain error'); + assert(bat.errors.domain, 'model should have a domain error'); + }); + }); + + describe('Model.validatesNumericalityOf(property, options)', function() { + it("Require a value for `property` to be a specific type of `Number`", function() { + User.validatesNumericalityOf('age', {int: true}); + var joe = new User({age: 10.2}); + assert(joe.isValid() === false); + var bob = new User({age: 0}); + assert(bob.isValid() === true); + assert(joe.errors.age, 'model should have an age error'); + }); + }); + + describe('myModel.isValid()', function() { + it("Validate the model instance", function() { + User.validatesNumericalityOf('age', {int: true}); + var user = new User({first: 'joe', age: 'flarg'}) + var valid = user.isValid(); + assert(valid === false); + assert(user.errors.age, 'model should have age error'); + }); + + it('Asynchronously validate the model', function(done) { + User.validatesNumericalityOf('age', {int: true}); + var user = new User({first: 'joe', age: 'flarg'}); + user.isValid(function (valid) { + assert(valid === false); + assert(user.errors.age, 'model should have age error'); + done(); + }); + }); + }); + + describe('Model.create([data], [callback])', function() { + it("Create an instance of Model with given data and save to the attached data source", function(done) { + User.create({first: 'Joe', last: 'Bob'}, function(err, user) { + assert(user instanceof User); + done(); + }); + }); + }); + + describe('model.save([options], [callback])', function() { + it("Save an instance of a Model to the attached data source", function(done) { + var joe = new User({first: 'Joe', last: 'Bob'}); + joe.save(function(err, user) { + assert(user.id); + assert(!err); + assert(!user.errors); + done(); + }); + }); + }); + + describe('model.updateAttributes(data, [callback])', function() { + it("Save specified attributes to the attached data source", function(done) { + User.create({first: 'joe', age: 100}, function (err, user) { + assert(!err); + assert.equal(user.first, 'joe'); + + user.updateAttributes({ + first: 'updatedFirst', + last: 'updatedLast' + }, function (err, updatedUser) { + assert(!err); + assert.equal(updatedUser.first, 'updatedFirst'); + assert.equal(updatedUser.last, 'updatedLast'); + assert.equal(updatedUser.age, 100); + done(); + }); + }); + }); + }); + + describe('Model.upsert(data, callback)', function() { + it("Update when record with id=data.id found, insert otherwise", function(done) { + User.upsert({first: 'joe', id: 7}, function (err, user) { + assert(!err); + assert.equal(user.first, 'joe'); + + User.upsert({first: 'bob', id: 7}, function (err, updatedUser) { + assert(!err); + assert.equal(updatedUser.first, 'bob'); + done(); + }); + }); + }); + }); + + describe('model.destroy([callback])', function() { + it("Remove a model from the attached data source", function(done) { + User.create({first: 'joe', last: 'bob'}, function (err, user) { + User.findById(user.id, function (err, foundUser) { + assert.equal(user.id, foundUser.id); + foundUser.destroy(function () { + User.findById(user.id, function (err, notFound) { + assert.equal(notFound, null); + done(); + }); + }); + }); + }); + }); + }); + + describe('Model.deleteById(id, [callback])', function () { + it("Delete a model instance from the attached data source", function (done) { + User.create({first: 'joe', last: 'bob'}, function (err, user) { + User.deleteById(user.id, function (err) { + User.findById(user.id, function (err, notFound) { + assert.equal(notFound, null); + done(); + }); + }); + }); + }); + }); + + describe('Model.findById(id, callback)', function() { + it("Find an instance by id", function(done) { + User.create({first: 'michael', last: 'jordan', id: 23}, function () { + User.findById(23, function (err, user) { + assert.equal(user.id, 23); + assert.equal(user.first, 'michael'); + assert.equal(user.last, 'jordan'); + done(); + }); + }); + }); + }); + + describe('Model.count([query], callback)', function() { + it("Query count of Model instances in data source", function(done) { + (new TaskEmitter()) + .task(User, 'create', {first: 'jill', age: 100}) + .task(User, 'create', {first: 'bob', age: 200}) + .task(User, 'create', {first: 'jan'}) + .task(User, 'create', {first: 'sam'}) + .task(User, 'create', {first: 'suzy'}) + .on('done', function () { + User.count({age: {gt: 99}}, function (err, count) { + assert.equal(count, 2); + done(); + }); + }); + }); + }); + +}); + + +}