diff --git a/README.md b/README.md index ef5b6371..d049198c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ For more details, see http://loopback.io/. In addition to the [main LoopBack module](https://github.com/strongloop/loopback), LoopBack consists of numerous other modules that implement specific functionality, as illustrated below: -![LoopBack modules](./docs/assets/lb-modules.png "LoopBack modules") +![LoopBack modules](https://github.com/strongloop/loopback/raw/master/docs/assets/lb-modules.png "LoopBack modules") * Frameworks * [loopback](https://github.com/strongloop/loopback) @@ -30,6 +30,7 @@ as illustrated below: * [loopback-connector-mongodb](https://github.com/strongloop/loopback-connector-mongodb) * [loopback-connector-mysql](https://github.com/strongloop/loopback-connector-mysql) * [loopback-connector-oracle](https://github.com/strongloop/loopback-connector-oracle) + * [loopback-connector-mssql](https://github.com/strongloop/loopback-connector-mssql) * [loopback-connector-postgresql](https://github.com/strongloop/loopback-connector-postgresql) * [loopback-connector-rest](https://github.com/strongloop/loopback-connector-rest) * [loopback-connector-soap](https://github.com/strongloop/loopback-connector-soap) @@ -57,7 +58,7 @@ as illustrated below: * [loopback-example-access-control](https://github.com/strongloop/loopback-example-access-control) * [loopback-example-proxy](https://github.com/strongloop/loopback-example-proxy) * [strongloop-community/loopback-example-datagraph](https://github.com/strongloop-community/loopback-example-datagraph) - * [strongloop-community/loopback-mysql-example](https://github.com/strongloop-community/loopback-mysql-example) + * [strongloop-community/loopback-example-database](https://github.com/strongloop-community/loopback-example-database) * [strongloop-community/loopback-examples-ios](https://github.com/strongloop-community/loopback-examples-ios) * [strongloop-community/loopback-example-ssl](https://github.com/strongloop-community/loopback-example-ssl) diff --git a/docs/assets/lb-modules.png b/docs/assets/lb-modules.png index 24c7bcee..390b86ea 100644 Binary files a/docs/assets/lb-modules.png and b/docs/assets/lb-modules.png differ diff --git a/lib/application.js b/lib/application.js index d14d6db1..3eb7a974 100644 --- a/lib/application.js +++ b/lib/application.js @@ -7,6 +7,7 @@ var DataSource = require('loopback-datasource-juggler').DataSource , compat = require('./compat') , assert = require('assert') , fs = require('fs') + , extend = require('util')._extend , _ = require('underscore') , RemoteObjects = require('strong-remoting') , swagger = require('strong-remoting/ext/swagger') @@ -197,9 +198,30 @@ app.models = function () { app.dataSource = function (name, config) { this.dataSources[name] = this.dataSources[classify(name)] = - this.dataSources[camelize(name)] = dataSourcesFromConfig(config); + this.dataSources[camelize(name)] = + dataSourcesFromConfig(config, this.connectors); } +/** + * Register a connector. + * + * When a new data-source is being added via `app.dataSource`, the connector + * name is looked up in the registered connectors first. + * + * Connectors are required to be explicitly registered only for applications + * using browserify, because browserify does not support dynamic require, + * which is used by LoopBack to automatically load the connector module. + * + * @param {String} name Name of the connector, e.g. 'mysql'. + * @param {Object} connector Connector object as returned + * by `require('loopback-connector-{name}')`. + */ +app.connector = function(name, connector) { + this.connectors[name] = + this.connectors[classify(name)] = + this.connectors[camelize(name)] = connector; +}; + /** * Get all remote objects. * @returns {Object} [Remote objects](http://apidocs.strongloop.com/strong-remoting/#remoteobjectsoptions). @@ -451,10 +473,10 @@ app.boot = function(options) { app.set('port', appConfig.port); } - assert(appConfig.restApiRoot !== undefined, 'app.restBasePath is required'); - assert(typeof appConfig.restApiRoot === 'string', 'app.restBasePath must be a string'); - assert(/^\//.test(appConfig.restApiRoot), 'app.restBasePath must start with "/"'); - app.set('restApiRoot', appConfig.restBasePath); + assert(appConfig.restApiRoot !== undefined, 'app.restApiRoot is required'); + assert(typeof appConfig.restApiRoot === 'string', 'app.restApiRoot must be a string'); + assert(/^\//.test(appConfig.restApiRoot), 'app.restApiRoot must start with "/"'); + app.set('restApiRoot', appConfig.restApiRoot); for(var configKey in appConfig) { var cur = app.get(configKey); @@ -519,17 +541,22 @@ function camelize(str) { return stringUtils.camelize(str); } -function dataSourcesFromConfig(config) { +function dataSourcesFromConfig(config, connectorRegistry) { var connectorPath; assert(typeof config === 'object', 'cannont create data source without config object'); if(typeof config.connector === 'string') { - connectorPath = path.join(__dirname, 'connectors', config.connector+'.js'); + var name = config.connector; + if (connectorRegistry[name]) { + config.connector = connectorRegistry[name]; + } else { + connectorPath = path.join(__dirname, 'connectors', name + '.js'); - if(fs.existsSync(connectorPath)) { - config.connector = require(connectorPath); + if (fs.existsSync(connectorPath)) { + config.connector = require(connectorPath); + } } } @@ -537,7 +564,10 @@ function dataSourcesFromConfig(config) { } function modelFromConfig(name, config, app) { - var ModelCtor = require('./loopback').createModel(name, config.properties, config.options); + var options = buildModelOptionsFromConfig(config); + var properties = config.properties; + + var ModelCtor = require('./loopback').createModel(name, properties, options); var dataSource = config.dataSource; if(typeof dataSource === 'string') { @@ -550,6 +580,26 @@ function modelFromConfig(name, config, app) { return ModelCtor; } +function buildModelOptionsFromConfig(config) { + var options = extend({}, config.options); + for (var key in config) { + if (['properties', 'options', 'dataSource'].indexOf(key) !== -1) { + // Skip items which have special meaning + continue; + } + + if (options[key] !== undefined) { + // When both `config.key` and `config.options.key` are set, + // use the latter one to preserve backwards compatibility + // with loopback 1.x + continue; + } + + options[key] = config[key]; + } + return options; +} + function requireDir(dir, basenames) { assert(dir, 'cannot require directory contents without directory name'); diff --git a/lib/loopback.js b/lib/loopback.js index 3a027713..a7a4c5da 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -76,6 +76,18 @@ function createApplication() { return proto.models.apply(this, arguments); }; + // Create a new instance of datasources registry per each app instance + app.datasources = app.dataSources = {}; + + // Create a new instance of connector registry per each app instance + app.connectors = {}; + + // Register built-in connectors. It's important to keep this code + // hand-written, so that all require() calls are static + // and thus browserify can process them (include connectors in the bundle) + app.connector('memory', loopback.Memory); + app.connector('remote', loopback.Remote); + return app; } diff --git a/lib/middleware/rest.js b/lib/middleware/rest.js index 9ddef750..3f87c126 100644 --- a/lib/middleware/rest.js +++ b/lib/middleware/rest.js @@ -15,17 +15,37 @@ module.exports = rest; */ function rest() { + var tokenParser = null; return function (req, res, next) { var app = req.app; var handler = app.handler('rest'); - + if(req.url === '/routes') { res.send(handler.adapter.allRoutes()); } else if(req.url === '/models') { return res.send(app.remotes().toJSON()); + } else if (app.isAuthEnabled) { + if (!tokenParser) { + // NOTE(bajtos) It would be better to search app.models for a model + // of type AccessToken instead of searching all loopback models. + // Unfortunately that's not supported now. + // Related discussions: + // https://github.com/strongloop/loopback/pull/167 + // https://github.com/strongloop/loopback/commit/f07446a + var AccessToken = loopback.getModelByType(loopback.AccessToken); + tokenParser = loopback.token({ model: AccessToken }); + } + + tokenParser(req, res, function(err) { + if (err) { + next(err); + } else { + handler(req, res, next); + } + }); } else { handler(req, res, next); } - } + }; } diff --git a/lib/middleware/token.js b/lib/middleware/token.js index 185a71ce..88a93c24 100644 --- a/lib/middleware/token.js +++ b/lib/middleware/token.js @@ -53,12 +53,14 @@ function token(options) { assert(TokenModel, 'loopback.token() middleware requires a AccessToken model'); return function (req, res, next) { + if (req.accessToken !== undefined) return next(); TokenModel.findForRequest(req, options, function(err, token) { if(err) return next(err); if(token) { req.accessToken = token; next(); } else { + req.accessToken = null; return next(); } }); diff --git a/package.json b/package.json index f56510f7..4b1b9e96 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,29 @@ { "name": "loopback", - "description": "LoopBack: Open Mobile Platform for Node.js", + "description": "LoopBack: Open API Framework for Node.js", "homepage": "http://loopback.io", "keywords": [ + "restful", + "rest", + "api", + "express", + "restify", + "koa", + "auth", + "security", + "oracle", + "mysql", + "nosql", + "mongo", + "mongodb", + "sqlserver", + "mssql", + "postgres", + "postgresql", + "soap", "StrongLoop", - "LoopBack", - "Mobile", - "Backend", - "Platform", + "framework", + "mobile", "mBaaS" ], "version": "2.0.0-beta2", @@ -15,19 +31,19 @@ "test": "mocha -R spec" }, "dependencies": { - "debug": "~0.7.4", - "express": "~3.4.8", + "debug": "~0.8.1", + "express": "~3.5.0", "strong-remoting": "~1.4.0", "inflection": "~1.3.5", "passport": "~0.2.0", - "passport-local": "~0.1.6", - "nodemailer": "~0.6.0", - "ejs": "~0.8.5", + "passport-local": "~1.0.0", + "nodemailer": "~0.6.5", + "ejs": "~1.0.0", "bcryptjs": "~0.7.12", "underscore.string": "~2.3.3", "underscore": "~1.6.0", "uid2": "0.0.3", - "async": "~0.2.10", + "async": "~0.9.0", "canonical-json": "0.0.3" }, "peerDependencies": { @@ -35,26 +51,26 @@ }, "devDependencies": { "loopback-datasource-juggler": "2.0.0-beta1", - "mocha": "~1.17.1", + "mocha": "~1.18.0", "strong-task-emitter": "0.0.x", - "supertest": "~0.9.0", - "chai": "~1.9.0", + "supertest": "~0.12.1", + "chai": "~1.9.1", "loopback-testing": "~0.1.2", - "browserify": "~3.41.0", - "grunt": "~0.4.2", - "grunt-browserify": "~1.3.1", - "grunt-contrib-uglify": "~0.3.2", - "grunt-contrib-jshint": "~0.8.0", - "grunt-contrib-watch": "~0.5.3", + "browserify": "~4.1.5", + "grunt": "~0.4.5", + "grunt-browserify": "~2.1.0", + "grunt-contrib-uglify": "~0.4.0", + "grunt-contrib-jshint": "~0.10.0", + "grunt-contrib-watch": "~0.6.1", "karma-script-launcher": "~0.1.0", - "karma-chrome-launcher": "~0.1.2", + "karma-chrome-launcher": "~0.1.3", "karma-firefox-launcher": "~0.1.3", "karma-html2js-preprocessor": "~0.1.0", - "karma-phantomjs-launcher": "~0.1.2", - "karma": "~0.10.9", + "karma-phantomjs-launcher": "~0.1.4", + "karma": "~0.12.16", "karma-browserify": "~0.2.0", - "karma-mocha": "~0.1.1", - "grunt-karma": "~0.6.2", + "karma-mocha": "~0.1.3", + "grunt-karma": "~0.8.3" "loopback-explorer": "~1.1.0" }, "repository": { diff --git a/test/access-token.test.js b/test/access-token.test.js index de5f030b..cd4bc0d6 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -32,6 +32,27 @@ describe('loopback.token(options)', function() { .end(done); }); }); + + it('should skip when req.token is already present', function(done) { + var tokenStub = { id: 'stub id' }; + app.use(function(req, res, next) { + req.accessToken = tokenStub; + next(); + }); + app.use(loopback.token({ model: Token })); + app.get('/', function(req, res, next) { + res.send(req.accessToken); + }); + + request(app).get('/') + .set('Authorization', this.token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.body).to.eql(tokenStub); + done(); + }); + }); }); describe('AccessToken', function () { diff --git a/test/acl.test.js b/test/acl.test.js index 0f4f8597..bf7bdb86 100644 --- a/test/acl.test.js +++ b/test/acl.test.js @@ -14,8 +14,12 @@ function checkResult(err, result) { assert(!err); } -describe('security scopes', function () { +var ds = null; +before(function() { + ds = loopback.createDataSource({connector: loopback.Memory}); +}); +describe('security scopes', function () { beforeEach(function() { var ds = this.ds = loopback.createDataSource({connector: loopback.Memory}); testModel = loopback.DataModel.extend('testModel'); @@ -156,7 +160,6 @@ describe('security ACLs', function () { }); it("should honor defaultPermission from the model", function () { - var ds = this.ds; var Customer = ds.createModel('Customer', { name: { type: String, @@ -188,7 +191,6 @@ describe('security ACLs', function () { }); it("should honor static ACLs from the model", function () { - var ds = this.ds; var Customer = ds.createModel('Customer', { name: { type: String, @@ -226,7 +228,6 @@ describe('security ACLs', function () { it("should check access against LDL, ACL, and Role", function () { // var log = console.log; var log = function() {}; - var ds = this.ds; // Create User.create({name: 'Raymond', email: 'x@y.com', password: 'foobar'}, function (err, user) { diff --git a/test/app.test.js b/test/app.test.js index 41d6285c..35f9e46d 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -69,9 +69,11 @@ describe('app', function() { }); }); - describe('app.model(name, properties, options)', function () { - it('Sugar for defining a fully built model', function () { - var app = loopback(); + describe('app.model(name, config)', function () { + var app; + + beforeEach(function() { + app = loopback(); app.boot({ app: {port: 3000, host: '127.0.0.1'}, dataSources: { @@ -80,20 +82,44 @@ describe('app', function() { } } }); + }); + it('Sugar for defining a fully built model', function () { app.model('foo', { dataSource: 'db' }); var Foo = app.models.foo; - var f = new Foo; + var f = new Foo(); assert(f instanceof loopback.Model); }); + + it('interprets extra first-level keys as options', function() { + app.model('foo', { + dataSource: 'db', + base: 'User' + }); + + expect(app.models.foo.definition.settings.base).to.equal('User'); + }); + + it('prefers config.options.key over config.key', function() { + app.model('foo', { + dataSource: 'db', + base: 'User', + options: { + base: 'Application' + } + }); + + expect(app.models.foo.definition.settings.base).to.equal('Application'); + }); }); describe('app.models', function() { it('is unique per app instance', function() { + app.dataSource('db', { connector: 'memory' }); var Color = app.model('Color', { dataSource: 'db' }); expect(app.models.Color).to.equal(Color); var anotherApp = loopback(); @@ -101,6 +127,23 @@ describe('app', function() { }); }); + describe('app.dataSources', function() { + it('is unique per app instance', function() { + app.dataSource('ds', { connector: 'memory' }); + expect(app.datasources.ds).to.not.equal(undefined); + var anotherApp = loopback(); + expect(anotherApp.datasources.ds).to.equal(undefined); + }); + }); + + describe('app.dataSource', function() { + it('looks up the connector in `app.connectors`', function() { + app.connector('custom', loopback.Memory); + app.dataSource('custom', { connector: 'custom' }); + expect(app.dataSources.custom.name).to.equal(loopback.Memory.name); + }); + }); + describe('app.boot([options])', function () { beforeEach(function () { app.boot({ @@ -475,4 +518,39 @@ describe('app', function() { }); }); }); + + describe('app.connectors', function() { + it('is unique per app instance', function() { + app.connectors.foo = 'bar'; + var anotherApp = loopback(); + expect(anotherApp.connectors.foo).to.equal(undefined); + }); + + it('includes Remote connector', function() { + expect(app.connectors.remote).to.equal(loopback.Remote); + }); + + it('includes Memory connector', function() { + expect(app.connectors.memory).to.equal(loopback.Memory); + }); + }); + + describe('app.connector', function() { + // any connector will do + it('adds the connector to the registry', function() { + app.connector('foo-bar', loopback.Memory); + expect(app.connectors['foo-bar']).to.equal(loopback.Memory); + }); + + it('adds a classified alias', function() { + app.connector('foo-bar', loopback.Memory); + expect(app.connectors.FooBar).to.equal(loopback.Memory); + }); + + it('adds a camelized alias', function() { + app.connector('FOO-BAR', loopback.Memory); + console.log(app.connectors); + expect(app.connectors.FOOBAR).to.equal(loopback.Memory); + }); + }); }); diff --git a/test/rest.middleware.test.js b/test/rest.middleware.test.js new file mode 100644 index 00000000..3c1e9886 --- /dev/null +++ b/test/rest.middleware.test.js @@ -0,0 +1,86 @@ +describe('loopback.rest', function() { + beforeEach(function() { + app.dataSource('db', { connector: loopback.Memory }); + }); + + it('works out-of-the-box', function(done) { + app.model('MyModel', { dataSource: 'db' }); + app.use(loopback.rest()); + request(app).get('/mymodels') + .expect(200) + .end(done); + }); + + it('includes loopback.token when necessary', function(done) { + givenUserModelWithAuth(); + app.enableAuth(); + app.use(loopback.rest()); + + givenLoggedInUser(function(err, token) { + if (err) return done(err); + expect(token).instanceOf(app.models.accessToken); + request(app).get('/users/' + token.userId) + .set('Authorization', token.id) + .expect(200) + .end(done); + }); + }); + + it('does not include loopback.token when auth not enabled', function(done) { + var User = givenUserModelWithAuth(); + User.getToken = function(req, cb) { + cb(null, req.accessToken ? req.accessToken.id : null); + }; + loopback.remoteMethod(User.getToken, { + accepts: [{ type: 'object', http: { source: 'req' } }], + returns: [{ type: 'object', name: 'id' }] + }); + + app.use(loopback.rest()); + givenLoggedInUser(function(err, token) { + if (err) return done(err); + request(app).get('/users/getToken') + .set('Authorization', token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.body.id).to.equal(null); + done(); + }); + }); + }); + + function givenUserModelWithAuth() { + // NOTE(bajtos) It is important to create a custom AccessToken model here, + // in order to overwrite the entry created by previous tests in + // the global model registry + app.model('accessToken', { + options: { + base: 'AccessToken' + }, + dataSource: 'db' + }); + return app.model('user', { + options: { + base: 'User', + relations: { + accessTokens: { + model: 'accessToken', + type: 'hasMany', + foreignKey: 'userId' + } + } + }, + dataSource: 'db' + }); + } + function givenLoggedInUser(cb) { + var credentials = { email: 'user@example.com', password: 'pwd' }; + var User = app.models.user; + User.create(credentials, + function(err, user) { + if (err) return done(err); + User.login(credentials, cb); + }); + } +}); diff --git a/test/user.test.js b/test/user.test.js index 7f5cedbb..25b1df8f 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -51,16 +51,15 @@ describe('User', function(){ it('Email is required', function (done) { User.create({password: '123'}, function (err) { - assert.deepEqual(err, {name: "ValidationError", - message: "The Model instance is not valid. See `details` " - + "property of the error object for more info.", - statusCode: 422, - details: { - context: "user", - codes: {email: ["presence", "format.blank", "uniqueness"]}, - messages: {email: ["can't be blank", "is blank", - "Email already exists"]}}} - ); + assert(err); + assert.equal(err.name, "ValidationError"); + assert.equal(err.statusCode, 422); + assert.equal(err.details.context, "user"); + assert.deepEqual(err.details.codes.email, [ + 'presence', + 'format.blank', + 'uniqueness' + ]); done(); });