diff --git a/README.md b/README.md index 3f7bb033..a8beab86 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ as illustrated below: ## Resources - * [Documentation](http://docs.strongloop.com/display/DOC/LoopBack). + * [Documentation](http://docs.strongloop.com/display/LB/LoopBack). * [API documentation](http://apidocs.strongloop.com/loopback). * [LoopBack Google Group](https://groups.google.com/forum/#!forum/loopbackjs). * [GitHub issues](https://github.com/strongloop/loopback/issues). diff --git a/docs.json b/docs.json index f05d25c8..4517a139 100644 --- a/docs.json +++ b/docs.json @@ -3,6 +3,8 @@ "content": [ "lib/application.js", "lib/loopback.js", + "lib/runtime.js", + "lib/registry.js", { "title": "Base model", "depth": 2 }, "lib/models/model.js", "lib/models/data-model.js", diff --git a/lib/application.js b/lib/application.js index 0c373fed..3b6d9dc6 100644 --- a/lib/application.js +++ b/lib/application.js @@ -3,7 +3,7 @@ */ var DataSource = require('loopback-datasource-juggler').DataSource - , ModelBuilder = require('loopback-datasource-juggler').ModelBuilder + , registry = require('./registry') , compat = require('./compat') , assert = require('assert') , fs = require('fs') @@ -82,60 +82,94 @@ app.disuse = function (route) { } /** - * Define and attach a model to the app. The `Model` will be available on the + * Attach a model to the app. The `Model` will be available on the * `app.models` object. * * ```js - * var Widget = app.model('Widget', {dataSource: 'db'}); - * Widget.create({name: 'pencil'}); - * app.models.Widget.find(function(err, widgets) { - * console.log(widgets[0]); // => {name: 'pencil'} + * // Attach an existing model + * var User = loopback.User; + * app.model(User); + * + * // Attach an existing model, alter some aspects of the model + * var User = loopback.User; + * app.model(User, { dataSource: 'db' }); + * + * // The old way: create and attach a new model (deprecated) + * var Widget = app.model('Widget', { + * dataSource: 'db', + * properties: { + * name: 'string' + * } * }); * ``` * - * @param {String} modelName The name of the model to define. + * @param {Object|String} Model The model to attach. * @options {Object} config The model's configuration. - * @property {String|DataSource} dataSource The `DataSource` to which to attach the model. - * @property {Object} [options] an object containing `Model` options. - * @property {ACL[]} [options.acls] an array of `ACL` definitions. - * @property {String[]} [options.hidden] **experimental** an array of properties to hide when accessed remotely. - * @property {Object} [properties] object defining the `Model` properties in [LoopBack Definition Language](http://docs.strongloop.com/loopback-datasource-juggler/#loopback-definition-language). + * @property {String|DataSource} dataSource The `DataSource` to which to + * attach the model. + * @property {Boolean} [public] whether the model should be exposed via REST API + * @property {Object} [relations] relations to add/update * @end * @returns {ModelConstructor} the model class */ app.model = function (Model, config) { - if(arguments.length === 1) { - assert(typeof Model === 'function', 'app.model(Model) => Model must be a function / constructor'); - assert(Model.modelName, 'Model must have a "modelName" property'); + var isPublic = true; + if (arguments.length > 1) { + config = config || {}; + if (typeof Model === 'string') { + // create & attach the model - backwards compatibility + + // create config for loopback.modelFromConfig + var modelConfig = extend({}, config); + modelConfig.options = extend({}, config.options); + modelConfig.name = Model; + + // modeller does not understand `dataSource` option + delete modelConfig.dataSource; + + Model = registry.createModel(modelConfig); + + // delete config options already applied + ['relations', 'base', 'acls', 'hidden'].forEach(function(prop) { + delete config[prop]; + if (config.options) delete config.options[prop]; + }); + delete config.properties; + } + + configureModel(Model, config, this); + isPublic = config.public !== false; + } else { + assert(Model.prototype instanceof registry.Model, + 'Model must be a descendant of loopback.Model'); + } + + var modelName = Model.modelName; + this.models[modelName] = + this.models[classify(modelName)] = + this.models[camelize(modelName)] = Model; + + this.models().push(Model); + + if (isPublic) { var remotingClassName = compat.getClassNameForRemoting(Model); this.remotes().exports[remotingClassName] = Model; - this.models().push(Model); clearHandlerCache(this); - Model.shared = true; - Model.app = this; - Model.emit('attached', this); - return; - } - var modelName = Model; - config = config || {}; - assert(typeof modelName === 'string', 'app.model(name, config) => "name" name must be a string'); - - Model = - this.models[modelName] = - this.models[classify(modelName)] = - this.models[camelize(modelName)] = modelFromConfig(modelName, config, this); - - if(config.public !== false) { - this.model(Model); } + Model.shared = isPublic; // The base Model has shared = true + Model.app = this; + Model.emit('attached', this); return Model; -} +}; /** * Get the models exported by the app. Returns only models defined using `app.model()` * + * **Deprecated. Use the package + * [loopback-boot](https://github.com/strongloop/loopback-boot) instead.** + * There are two ways to access models: * * 1. Call `app.models()` to get a list of all models. @@ -295,6 +329,7 @@ app.dataSources = app.datasources = {}; app.enableAuth = function() { var remotes = this.remotes(); + var app = this; remotes.before('**', function(ctx, next, method) { var req = ctx.req; @@ -302,6 +337,12 @@ app.enableAuth = function() { var modelInstance = ctx.instance; var modelId = modelInstance && modelInstance.id || req.param('id'); + var modelSettings = Model.settings || {}; + var errStatusCode = modelSettings.aclErrorStatus || app.get('aclErrorStatus') || 401; + if(!req.accessToken){ + errStatusCode = 401; + } + if(Model.checkAccess) { // Pause the request before checking access // See https://github.com/strongloop/loopback-storage-service/issues/7 @@ -319,8 +360,15 @@ app.enableAuth = function() { } else if(allowed) { next(); } else { - var e = new Error('Access Denied'); - e.statusCode = 401; + + var messages = { + 403:'Access Denied', + 404: ('could not find a model with id ' + modelId), + 401:'Authorization Required' + }; + + var e = new Error(messages[errStatusCode] || messages[403]); + e.statusCode = errStatusCode; next(e); } } @@ -500,7 +548,7 @@ app.boot = function(options) { // try to attach models to dataSources by type try { - require('./loopback').autoAttach(); + registry.autoAttach(); } catch(e) { if(e.name === 'AssertionError') { console.warn(e); @@ -563,44 +611,27 @@ function dataSourcesFromConfig(config, connectorRegistry) { } } - return require('./loopback').createDataSource(config); + return registry.createDataSource(config); } -function modelFromConfig(name, config, app) { - var options = buildModelOptionsFromConfig(config); - var properties = config.properties; +function configureModel(ModelCtor, config, app) { + assert(ModelCtor.prototype instanceof registry.Model, + 'Model must be a descendant of loopback.Model'); - var ModelCtor = require('./loopback').createModel(name, properties, options); var dataSource = config.dataSource; if(typeof dataSource === 'string') { dataSource = app.dataSources[dataSource]; } - assert(isDataSource(dataSource), name + ' is referencing a dataSource that does not exist: "'+ config.dataSource +'"'); + assert(dataSource instanceof DataSource, + ModelCtor.modelName + ' is referencing a dataSource that does not exist: "' + + config.dataSource +'"'); - ModelCtor.attachTo(dataSource); - return ModelCtor; -} + config = extend({}, config); + config.dataSource = dataSource; -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; + registry.configureModel(ModelCtor, config); } function requireDir(dir, basenames) { @@ -672,14 +703,6 @@ function tryReadDir() { } } -function isModelCtor(obj) { - return typeof obj === 'function' && obj.modelName && obj.name === 'ModelCtor'; -} - -function isDataSource(obj) { - return obj instanceof DataSource; -} - function tryReadConfig(cwd, fileName) { try { return require(path.join(cwd, fileName + '.json')); diff --git a/lib/browser-express.js b/lib/browser-express.js index 386e8159..82aba2fa 100644 --- a/lib/browser-express.js +++ b/lib/browser-express.js @@ -1,7 +1,25 @@ module.exports = browserExpress; function browserExpress() { - return {}; + return new BrowserExpress(); } browserExpress.errorHandler = {}; + +function BrowserExpress() { + this.settings = {}; +} + +BrowserExpress.prototype.set = function(key, value) { + if (arguments.length == 1) { + return this.get(key); + } + + this.settings[key] = value; + + return this; // fluent API +}; + +BrowserExpress.prototype.get = function(key) { + return this.settings[key]; +}; diff --git a/lib/connectors/mail.js b/lib/connectors/mail.js index c0337f46..5660adcc 100644 --- a/lib/connectors/mail.js +++ b/lib/connectors/mail.js @@ -4,9 +4,8 @@ var mailer = require('nodemailer') , assert = require('assert') - , debug = require('debug') + , debug = require('debug')('loopback:connector:mail') , loopback = require('../loopback') - , STUB = 'STUB'; /** * Export the MailConnector class. diff --git a/lib/loopback.js b/lib/loopback.js index d2017ee7..cdc9d296 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -3,11 +3,11 @@ */ var express = require('express') + , proto = require('./application') , fs = require('fs') , ejs = require('ejs') , EventEmitter = require('events').EventEmitter , path = require('path') - , proto = require('./application') , DataSource = require('loopback-datasource-juggler').DataSource , ModelBuilder = require('loopback-datasource-juggler').ModelBuilder , i8n = require('inflection') @@ -30,18 +30,6 @@ var express = require('express') var loopback = exports = module.exports = createApplication; -/** - * True if running in a browser environment; false otherwise. - */ - -loopback.isBrowser = typeof window !== 'undefined'; - -/** - * True if running in a server environment; false otherwise. - */ - -loopback.isServer = !loopback.isBrowser; - /** * Framework version. */ @@ -71,6 +59,8 @@ function createApplication() { merge(app, proto); + app.loopback = loopback; + // Create a new instance of models registry per each app instance app.models = function() { return proto.models.apply(this, arguments); @@ -91,17 +81,23 @@ function createApplication() { return app; } +function mixin(source) { + for (var key in source) { + var desc = Object.getOwnPropertyDescriptor(source, key); + Object.defineProperty(loopback, key, desc); + } +} + +mixin(require('./runtime')); +mixin(require('./registry')); + /*! * Expose express.middleware as loopback.* * for example `loopback.errorHandler` etc. */ -for (var key in express) { - Object.defineProperty( - loopback - , key - , Object.getOwnPropertyDescriptor(express, key)); -} +mixin(express); + /*! * Expose additional loopback middleware @@ -127,58 +123,6 @@ if (loopback.isServer) { loopback.errorHandler.title = 'Loopback'; -/** - * Create a data source with passing the provided options to the connector. - * - * @param {String} name Optional name. - * @options {Object} Data Source options - * @property {Object} connector LoopBack connector. - * @property {*} Other properties See the relevant connector documentation. - */ - -loopback.createDataSource = function (name, options) { - var ds = new DataSource(name, options, loopback.Model.modelBuilder); - ds.createModel = function (name, properties, settings) { - var ModelCtor = loopback.createModel(name, properties, settings); - ModelCtor.attachTo(ds); - return ModelCtor; - }; - - if(ds.settings && ds.settings.defaultForType) { - loopback.setDefaultDataSourceForType(ds.settings.defaultForType, ds); - } - - return ds; -}; - -/** - * Create a named vanilla JavaScript class constructor with an attached set of properties and options. - * - * @param {String} name Unique name. - * @param {Object} properties - * @param {Object} options (optional) - */ - -loopback.createModel = function (name, properties, options) { - options = options || {}; - var BaseModel = options.base || options.super; - - if(typeof BaseModel === 'string') { - BaseModel = loopback.getModel(BaseModel); - } - - BaseModel = BaseModel || loopback.Model; - - var model = BaseModel.extend(name, properties, options); - - // try to attach - try { - loopback.autoAttachModel(model); - } catch(e) {} - - return model; -}; - /** * Add a remote method to a model. * @param {Function} fn @@ -211,119 +155,11 @@ loopback.template = function (file) { return ejs.compile(str); }; -/** - * Get an in-memory data source. Use one if it already exists. - * - * @param {String} [name] The name of the data source. If not provided, the `'default'` is used. - */ - -loopback.memory = function (name) { - name = name || 'default'; - var memory = ( - this._memoryDataSources - || (this._memoryDataSources = {}) - )[name]; - - if(!memory) { - memory = this._memoryDataSources[name] = loopback.createDataSource({ - connector: loopback.Memory - }); - } - - return memory; -}; - -/** - * Look up a model class by name from all models created by loopback.createModel() - * @param {String} modelName The model name - * @returns {Model} The model class - */ -loopback.getModel = function(modelName) { - return loopback.Model.modelBuilder.models[modelName]; -}; - -/** - * Look up a model class by the base model class. The method can be used by LoopBack - * to find configured models in models.json over the base model. - * @param {Model} The base model class - * @returns {Model} The subclass if found or the base class - */ -loopback.getModelByType = function(modelType) { - assert(typeof modelType === 'function', 'The model type must be a constructor'); - var models = loopback.Model.modelBuilder.models; - for(var m in models) { - if(models[m].prototype instanceof modelType) { - return models[m]; - } - } - return modelType; -}; - -/** - * Set the default `dataSource` for a given `type`. - * @param {String} type The datasource type - * @param {Object|DataSource} dataSource The data source settings or instance - * @returns {DataSource} The data source instance - */ - -loopback.setDefaultDataSourceForType = function(type, dataSource) { - var defaultDataSources = this.defaultDataSources || (this.defaultDataSources = {}); - - if(!(dataSource instanceof DataSource)) { - dataSource = this.createDataSource(dataSource); - } - - defaultDataSources[type] = dataSource; - return dataSource; -}; - -/** - * Get the default `dataSource` for a given `type`. - * @param {String} type The datasource type - * @returns {DataSource} The data source instance - */ - -loopback.getDefaultDataSourceForType = function(type) { - return this.defaultDataSources && this.defaultDataSources[type]; -}; - -/** - * Attach any model that does not have a dataSource to - * the default dataSource for the type the Model requests - */ - -loopback.autoAttach = function() { - var models = this.Model.modelBuilder.models; - assert.equal(typeof models, 'object', 'Cannot autoAttach without a models object'); - - Object.keys(models).forEach(function(modelName) { - var ModelCtor = models[modelName]; - - // Only auto attach if the model doesn't have an explicit data source - if(ModelCtor && (!(ModelCtor.dataSource instanceof DataSource))) { - loopback.autoAttachModel(ModelCtor); - } - }); -}; - -loopback.autoAttachModel = function(ModelCtor) { - if(ModelCtor.autoAttach) { - var ds = loopback.getDefaultDataSourceForType(ModelCtor.autoAttach); - - assert(ds instanceof DataSource, 'cannot autoAttach model "' - + ModelCtor.modelName - + '". No dataSource found of type ' + ModelCtor.autoAttach); - - ModelCtor.attachTo(ds); - } -}; /*! * Built in models / services */ -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'); diff --git a/lib/models/access-token.js b/lib/models/access-token.js index f5f66d20..2171016e 100644 --- a/lib/models/access-token.js +++ b/lib/models/access-token.js @@ -191,11 +191,9 @@ function tokenIdForRequest(req, options) { var length; var id; - params.push('access_token'); - headers.push('X-Access-Token'); - headers.push('authorization'); - cookies.push('access_token'); - cookies.push('authorization'); + params = params.concat(['access_token']); + headers = headers.concat(['X-Access-Token', 'authorization']); + cookies = cookies.concat(['access_token', 'authorization']); for(length = params.length; i < length; i++) { id = req.param(params[i]); diff --git a/lib/models/acl.js b/lib/models/acl.js index b29b3e63..a10cfbbc 100644 --- a/lib/models/acl.js +++ b/lib/models/acl.js @@ -61,6 +61,7 @@ var ACLSchema = { /** * Name of the access type - READ/WRITE/EXEC + * @property accessType {String} Name of the access type - READ/WRITE/EXEC */ accessType: String, @@ -114,7 +115,7 @@ ACL.SCOPE = Principal.SCOPE; * Calculate the matching score for the given rule and request * @param {ACL} rule The ACL entry * @param {AccessRequest} req The request - * @returns {number} + * @returns {Number} */ ACL.getMatchingScore = function getMatchingScore(rule, req) { var props = ['model', 'property', 'accessType']; @@ -296,14 +297,12 @@ ACL.getStaticACLs = function getStaticACLs(model, property) { /** * Check if the given principal is allowed to access the model/property - * @param {String} principalType The principal type - * @param {String} principalId The principal id - * @param {String} model The model name - * @param {String} property The property/method/relation name - * @param {String} accessType The access type - * @param {Function} callback The callback function - * - * @callback callback + * @param {String} principalType The principal type. + * @param {String} principalId The principal ID. + * @param {String} model The model name. + * @param {String} property The property/method/relation name. + * @param {String} accessType The access type. + * @callback {Function} callback Callback function. * @param {String|Error} err The error object * @param {AccessRequest} result The access permission */ @@ -364,14 +363,14 @@ ACL.prototype.debug = function() { } /** - * Check if the request has the permission to access - * @param {Object} context - * @property {Object[]} principals An array of principals - * @property {String|Model} model The model name or model class - * @property {*} id The model instance id - * @property {String} property The property/method/relation name - * @property {String} accessType The access type - * @param {Function} callback + * Check if the request has the permission to access. + * @options {Object} context See below. + * @property {Object[]} principals An array of principals. + * @property {String|Model} model The model name or model class. + * @property {*} id The model instance ID. + * @property {String} property The property/method/relation name. + * @property {String} accessType The access type: READE, WRITE, or EXEC. + * @param {Function} callback Callback function */ ACL.checkAccessForContext = function (context, callback) { @@ -452,8 +451,7 @@ ACL.checkAccessForContext = function (context, callback) { * @param {String} model The model name * @param {*} modelId The model id * @param {String} method The method name - * @end - * @callback {Function} callback + * @callback {Function} callback Callback function * @param {String|Error} err The error object * @param {Boolean} allowed is the request allowed */ diff --git a/lib/models/application.js b/lib/models/application.js index f537559e..b562d278 100644 --- a/lib/models/application.js +++ b/lib/models/application.js @@ -46,7 +46,7 @@ var PushNotificationSettingSchema = { gcm: GcmSettingsSchema }; -/** +/*! * Data model for Application */ var ApplicationSchema = { @@ -133,10 +133,10 @@ Application.beforeCreate = function (next) { /** * Register a new application - * @param owner Owner's user id - * @param name Name of the application - * @param options Other options - * @param cb Callback function + * @param {String} owner Owner's user ID. + * @param {String} name Name of the application + * @param {Object} options Other options + * @param {Function} callback Callback function */ Application.register = function (owner, name, options, cb) { assert(owner, 'owner is required'); diff --git a/lib/models/model.js b/lib/models/model.js index fdeea317..ab1053c9 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -1,7 +1,7 @@ /*! * Module Dependencies. */ -var loopback = require('../loopback'); +var registry = require('../registry'); var compat = require('../compat'); var ModelBuilder = require('loopback-datasource-juggler').ModelBuilder; var modeler = new ModelBuilder(); @@ -128,7 +128,7 @@ Model._ACL = function getACL(ACL) { return _aclModel; } var aclModel = require('./acl').ACL; - _aclModel = loopback.getModelByType(aclModel); + _aclModel = registry.getModelByType(aclModel); return _aclModel; }; @@ -136,13 +136,11 @@ Model._ACL = function getACL(ACL) { * Check if the given access token can invoke the method * * @param {AccessToken} token The access token - * @param {*} modelId The model id - * @param {SharedMethod} sharedMethod - * @param callback The callback function - * - * @callback {Function} callback + * @param {*} modelId The model ID. + * @param {SharedMethod} sharedMethod The method in question + * @callback {Function} callback The callback function * @param {String|Error} err The error object - * @param {Boolean} allowed is the request allowed + * @param {Boolean} allowed True if the request is allowed; false otherwise. */ Model.checkAccess = function(token, modelId, sharedMethod, callback) { var ANONYMOUS = require('./access-token').ANONYMOUS; diff --git a/lib/models/role.js b/lib/models/role.js index 072b7a76..5e733b15 100644 --- a/lib/models/role.js +++ b/lib/models/role.js @@ -7,7 +7,7 @@ var AccessContext = require('./access-context').AccessContext; // Role model var RoleSchema = { - id: {type: String, id: true}, // Id + id: {type: String, id: true, generated: true}, // Id name: {type: String, required: true}, // The name of a role description: String, // Description @@ -20,8 +20,8 @@ var RoleSchema = { * Map principals to roles */ var RoleMappingSchema = { - id: {type: String, id: true}, // Id - roleId: String, // The role id + id: {type: String, id: true, generated: true}, // Id + // roleId: String, // The role id, to be injected by the belongsTo relation principalType: String, // The principal type, such as user, application, or role principalId: String // The principal id }; diff --git a/lib/registry.js b/lib/registry.js new file mode 100644 index 00000000..1c48e24c --- /dev/null +++ b/lib/registry.js @@ -0,0 +1,329 @@ +/* + * This file exports methods and objects for manipulating + * Models and DataSources. + * + * It is an internal file that should not be used outside of loopback. + * All exported entities can be accessed via the `loopback` object. + * @private + */ + +var assert = require('assert'); +var extend = require('util')._extend; +var DataSource = require('loopback-datasource-juggler').DataSource; + +var registry = module.exports; + +registry.defaultDataSources = {}; + +/** + * Create a named vanilla JavaScript class constructor with an attached + * set of properties and options. + * + * This function comes with two variants: + * * `loopback.createModel(name, properties, options)` + * * `loopback.createModel(config)` + * + * In the second variant, the parameters `name`, `properties` and `options` + * are provided in the config object. Any additional config entries are + * interpreted as `options`, i.e. the following two configs are identical: + * + * ```js + * { name: 'Customer', base: 'User' } + * { name: 'Customer', options: { base: 'User' } } + * ``` + * + * **Example** + * + * Create an `Author` model using the three-parameter variant: + * + * ```js + * loopback.createModel( + * 'Author', + * { + * firstName: 'string', + * lastName: 'string + * }, + * { + * relations: { + * books: { + * model: 'Book', + * type: 'hasAndBelongsToMany' + * } + * } + * } + * ); + * ``` + * + * Create the same model using a config object: + * + * ```js + * loopback.createModel({ + * name: 'Author', + * properties: { + * firstName: 'string', + * lastName: 'string + * }, + * relations: { + * books: { + * model: 'Book', + * type: 'hasAndBelongsToMany' + * } + * } + * }); + * ``` + * + * @param {String} name Unique name. + * @param {Object} properties + * @param {Object} options (optional) + * + * @header loopback.createModel + */ + +registry.createModel = function (name, properties, options) { + if (arguments.length === 1 && typeof name === 'object') { + var config = name; + name = config.name; + properties = config.properties; + options = buildModelOptionsFromConfig(config); + + assert(typeof name === 'string', + 'The model-config property `name` must be a string'); + } + + options = options || {}; + var BaseModel = options.base || options.super; + + if(typeof BaseModel === 'string') { + var baseName = BaseModel; + BaseModel = this.getModel(BaseModel); + + if (BaseModel === undefined) { + if (baseName === 'DataModel') { + console.warn('Model `%s` is extending deprecated `DataModel. ' + + 'Use `PeristedModel` instead.', name); + BaseModel = this.PersistedModel; + } else { + console.warn('Model `%s` is extending an unknown model `%s`. ' + + 'Using `PersistedModel` as the base.', name, baseName); + } + } + } + + BaseModel = BaseModel || this.Model; + + var model = BaseModel.extend(name, properties, options); + + // try to attach + try { + this.autoAttachModel(model); + } catch(e) {} + + return model; +}; + +function buildModelOptionsFromConfig(config) { + var options = extend({}, config.options); + for (var key in config) { + if (['name', 'properties', 'options'].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 + continue; + } + + options[key] = config[key]; + } + return options; +} + +/** + * Alter an existing Model class. + * @param {Model} ModelCtor The model constructor to alter. + * @options {Object} config Additional configuration to apply + * @property {DataSource} dataSource Attach the model to a dataSource. + * @property {Object} [relations] Model relations to add/update. + * + * @header loopback.configureModel(ModelCtor, config) + */ + +registry.configureModel = function(ModelCtor, config) { + var settings = ModelCtor.settings; + + if (config.relations) { + var relations = settings.relations = settings.relations || {}; + Object.keys(config.relations).forEach(function(key) { + relations[key] = extend(relations[key] || {}, config.relations[key]); + }); + } + + // It's important to attach the datasource after we have updated + // configuration, so that the datasource picks up updated relations + if (config.dataSource) { + assert(config.dataSource instanceof DataSource, + 'Cannot configure ' + ModelCtor.modelName + + ': config.dataSource must be an instance of DataSource'); + ModelCtor.attachTo(config.dataSource); + } +}; + +/** + * Look up a model class by name from all models created by + * `loopback.createModel()` + * @param {String} modelName The model name + * @returns {Model} The model class + * + * @header loopback.getModel(modelName) + */ +registry.getModel = function(modelName) { + return this.Model.modelBuilder.models[modelName]; +}; + +/** + * Look up a model class by the base model class. + * The method can be used by LoopBack + * to find configured models in models.json over the base model. + * @param {Model} modelType The base model class + * @returns {Model} The subclass if found or the base class + * + * @header loopback.getModelByType(modelType) + */ +registry.getModelByType = function(modelType) { + assert(typeof modelType === 'function', + 'The model type must be a constructor'); + var models = this.Model.modelBuilder.models; + for(var m in models) { + if(models[m].prototype instanceof modelType) { + return models[m]; + } + } + return modelType; +}; + +/** + * Create a data source with passing the provided options to the connector. + * + * @param {String} name Optional name. + * @options {Object} options Data Source options + * @property {Object} connector LoopBack connector. + * @property {*} [*] Other connector properties. + * See the relevant connector documentation. + * + * @header loopback.createDataSource(name, options) + */ + +registry.createDataSource = function (name, options) { + var loopback = this; + var ds = new DataSource(name, options, loopback.Model.modelBuilder); + ds.createModel = function (name, properties, settings) { + var ModelCtor = loopback.createModel(name, properties, settings); + ModelCtor.attachTo(ds); + return ModelCtor; + }; + + if(ds.settings && ds.settings.defaultForType) { + this.setDefaultDataSourceForType(ds.settings.defaultForType, ds); + } + + return ds; +}; + +/** + * Get an in-memory data source. Use one if it already exists. + * + * @param {String} [name] The name of the data source. + * If not provided, the `'default'` is used. + * + * @header loopback.memory() + */ + +registry.memory = function (name) { + name = name || 'default'; + var memory = ( + this._memoryDataSources || (this._memoryDataSources = {}) + )[name]; + + if(!memory) { + memory = this._memoryDataSources[name] = this.createDataSource({ + connector: loopback.Memory + }); + } + + return memory; +}; + +/** + * Set the default `dataSource` for a given `type`. + * @param {String} type The datasource type + * @param {Object|DataSource} dataSource The data source settings or instance + * @returns {DataSource} The data source instance + * + * @header loopback.setDefaultDataSourceForType(type, dataSource) + */ + +registry.setDefaultDataSourceForType = function(type, dataSource) { + var defaultDataSources = this.defaultDataSources; + + if(!(dataSource instanceof DataSource)) { + dataSource = this.createDataSource(dataSource); + } + + defaultDataSources[type] = dataSource; + return dataSource; +}; + +/** + * Get the default `dataSource` for a given `type`. + * @param {String} type The datasource type + * @returns {DataSource} The data source instance + * @header loopback.getDefaultDataSourceForType() + */ + +registry.getDefaultDataSourceForType = function(type) { + return this.defaultDataSources && this.defaultDataSources[type]; +}; + +/** + * Attach any model that does not have a dataSource to + * the default dataSource for the type the Model requests + * @header loopback.autoAttach() + */ + +registry.autoAttach = function() { + var models = this.Model.modelBuilder.models; + assert.equal(typeof models, 'object', 'Cannot autoAttach without a models object'); + + Object.keys(models).forEach(function(modelName) { + var ModelCtor = models[modelName]; + + // Only auto attach if the model doesn't have an explicit data source + if(ModelCtor && (!(ModelCtor.dataSource instanceof DataSource))) { + this.autoAttachModel(ModelCtor); + } + }, this); +}; + +registry.autoAttachModel = function(ModelCtor) { + if(ModelCtor.autoAttach) { + var ds = this.getDefaultDataSourceForType(ModelCtor.autoAttach); + + assert(ds instanceof DataSource, 'cannot autoAttach model "' + + ModelCtor.modelName + + '". No dataSource found of type ' + ModelCtor.autoAttach); + + ModelCtor.attachTo(ds); + } +}; + +registry.DataSource = DataSource; + +/* + * Core models + * @private + */ + +registry.Model = require('./models/model'); +registry.DataModel = require('./models/data-model'); diff --git a/lib/runtime.js b/lib/runtime.js new file mode 100644 index 00000000..f179c533 --- /dev/null +++ b/lib/runtime.js @@ -0,0 +1,22 @@ +/* + * This is an internal file that should not be used outside of loopback. + * All exported entities can be accessed via the `loopback` object. + * @private + */ + +var runtime = exports; + +/** + * True if running in a browser environment; false otherwise. + * @header loopback.isBrowser + */ + +runtime.isBrowser = typeof window !== 'undefined'; + +/** + * True if running in a server environment; false otherwise. + * @header loopback.isServer + */ + +runtime.isServer = !runtime.isBrowser; + diff --git a/package.json b/package.json index 1c7b6e29..2f190c88 100644 --- a/package.json +++ b/package.json @@ -26,47 +26,47 @@ "mobile", "mBaaS" ], - "version": "1.8.7", + "version": "1.9.0", "scripts": { "test": "mocha -R spec" }, "dependencies": { - "debug": "~0.8.1", + "debug": "~1.0.2", "express": "~3.5.0", "strong-remoting": "~1.5.0", - "inflection": "~1.3.5", - "nodemailer": "~0.6.5", + "inflection": "~1.3.7", + "nodemailer": "~0.7.0", "ejs": "~1.0.0", - "bcryptjs": "~0.7.12", + "bcryptjs": "~1.0.3", "underscore.string": "~2.3.3", "underscore": "~1.6.0", "uid2": "0.0.3", "async": "~0.9.0" }, "peerDependencies": { - "loopback-datasource-juggler": ">=1.4.0 <1.6.0" + "loopback-datasource-juggler": ">=1.4.0 <1.7.0" }, "devDependencies": { - "loopback-datasource-juggler": ">=1.4.0 <1.6.0", - "mocha": "~1.18.0", + "loopback-datasource-juggler": ">=1.4.0 <1.7.0", + "mocha": "~1.20.1", "strong-task-emitter": "0.0.x", - "supertest": "~0.12.1", + "supertest": "~0.13.0", "chai": "~1.9.1", - "loopback-testing": "~0.1.2", - "browserify": "~4.1.5", + "loopback-testing": "~0.2.0", + "browserify": "~4.1.11", "grunt": "~0.4.5", "grunt-browserify": "~2.1.0", - "grunt-contrib-uglify": "~0.4.0", + "grunt-contrib-uglify": "~0.5.0", "grunt-contrib-jshint": "~0.10.0", "grunt-contrib-watch": "~0.6.1", "karma-script-launcher": "~0.1.0", - "karma-chrome-launcher": "~0.1.3", + "karma-chrome-launcher": "~0.1.4", "karma-firefox-launcher": "~0.1.3", "karma-html2js-preprocessor": "~0.1.0", "karma-phantomjs-launcher": "~0.1.4", "karma": "~0.12.16", - "karma-browserify": "~0.2.0", - "karma-mocha": "~0.1.3", + "karma-browserify": "~0.2.1", + "karma-mocha": "~0.1.4", "grunt-karma": "~0.8.3" }, "repository": { diff --git a/test/access-control.integration.js b/test/access-control.integration.js index 1abb320e..575baebd 100644 --- a/test/access-control.integration.js +++ b/test/access-control.integration.js @@ -6,6 +6,7 @@ var app = require(path.join(ACCESS_CONTROL_APP, 'app.js')); var assert = require('assert'); var USER = {email: 'test@test.test', password: 'test'}; var CURRENT_USER = {email: 'current@test.test', password: 'test'}; +var debug = require('debug')('loopback:test:access-control.integration'); describe('access control - integration', function () { @@ -69,8 +70,11 @@ describe('access control - integration', function () { lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', urlForUser); lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER,'GET', urlForUser); - lt.it.shouldBeAllowedWhenCalledAnonymously('POST', '/api/users'); - lt.it.shouldBeAllowedWhenCalledByUser(CURRENT_USER, 'POST', '/api/users'); + lt.it.shouldBeAllowedWhenCalledAnonymously( + 'POST', '/api/users', newUserData()); + + lt.it.shouldBeAllowedWhenCalledByUser( + CURRENT_USER, 'POST', '/api/users', newUserData()); lt.it.shouldBeAllowedWhenCalledByUser(CURRENT_USER, 'POST', '/api/users/logout'); @@ -96,6 +100,10 @@ describe('access control - integration', function () { lt.describe.whenCalledRemotely('GET', '/api/users/:id', function() { lt.it.shouldBeAllowed(); it('should not include a password', function() { + debug('GET /api/users/:id response: %s\nheaders: %j\nbody string: %s', + this.res.statusCode, + this.res.headers, + this.res.text); var user = this.res.body; assert.equal(user.password, undefined); }); @@ -112,6 +120,15 @@ describe('access control - integration', function () { function urlForUser() { return '/api/users/' + this.randomUser.id; } + + var userCounter; + function newUserData() { + userCounter = userCounter ? ++userCounter : 1; + return { + email: 'new-' + userCounter + '@test.test', + password: 'test' + }; + } }); describe('/banks', function () { diff --git a/test/access-token.test.js b/test/access-token.test.js index 50cf4d9a..1f4e7344 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -81,13 +81,38 @@ describe('app.enableAuth()', function() { beforeEach(createTestingToken); - it('should prevent remote method calls if the accessToken doesnt have access', function (done) { + it('prevents remote call with 401 status on denied ACL', function (done) { createTestAppAndRequest(this.token, done) .del('/tests/123') .expect(401) .set('authorization', this.token.id) .end(done); }); + + it('prevent remote call with app setting status on denied ACL', function (done) { + createTestAppAndRequest(this.token, {app:{aclErrorStatus:403}}, done) + .del('/tests/123') + .expect(403) + .set('authorization', this.token.id) + .end(done); + }); + + it('prevent remote call with app setting status on denied ACL', function (done) { + createTestAppAndRequest(this.token, {model:{aclErrorStatus:404}}, done) + .del('/tests/123') + .expect(404) + .set('authorization', this.token.id) + .end(done); + }); + + it('prevent remote call if the accessToken is missing and required', function (done) { + createTestAppAndRequest(null, done) + .del('/tests/123') + .expect(401) + .set('authorization', null) + .end(done); + }); + }); function createTestingToken(done) { @@ -99,12 +124,19 @@ function createTestingToken(done) { }); } -function createTestAppAndRequest(testToken, done) { - var app = createTestApp(testToken, done); +function createTestAppAndRequest(testToken, settings, done) { + var app = createTestApp(testToken, settings, done); return request(app); } -function createTestApp(testToken, done) { +function createTestApp(testToken, settings, done) { + done = arguments[arguments.length-1]; + if(settings == done) settings = {}; + settings = settings || {}; + + var appSettings = settings.app || {}; + var modelSettings = settings.model || {}; + var app = loopback(); app.use(loopback.cookieParser('secret')); @@ -125,7 +157,11 @@ function createTestApp(testToken, done) { app.use(loopback.rest()); app.enableAuth(); - var TestModel = loopback.Model.extend('test', {}, { + Object.keys(appSettings).forEach(function(key){ + app.set(key, appSettings[key]); + }); + + var modelOptions = { acls: [ { principalType: "ROLE", @@ -135,8 +171,14 @@ function createTestApp(testToken, done) { property: 'removeById' } ] + }; + + Object.keys(modelSettings).forEach(function(key){ + modelOptions[key] = modelSettings[key]; }); + var TestModel = loopback.Model.extend('test', {}, modelOptions); + TestModel.attachTo(loopback.memory()); app.model(TestModel); diff --git a/test/app.test.js b/test/app.test.js index 74ea8923..139e24ea 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -29,6 +29,15 @@ describe('app', function() { expect(app.remotes().exports).to.eql({ color: Color }); }); + it('registers existing models to app.models', function() { + var Color = db.createModel('color', {name: String}); + app.model(Color); + expect(Color.app).to.be.equal(app); + expect(Color.shared).to.equal(true); + expect(app.models.color).to.equal(Color); + expect(app.models.Color).to.equal(Color); + }); + it('updates REST API when a new model is added', function(done) { app.use(loopback.rest()); request(app).get('/colors').expect(404, function(err, res) { @@ -107,6 +116,38 @@ describe('app', function() { expect(app.models.foo.definition.settings.base).to.equal('Application'); }); + + it('honors config.public options', function() { + app.model('foo', { + dataSource: 'db', + public: false + }); + expect(app.models.foo.app).to.equal(app); + expect(app.models.foo.shared).to.equal(false); + }); + + it('defaults config.public to be true', function() { + app.model('foo', { + dataSource: 'db' + }); + expect(app.models.foo.app).to.equal(app); + expect(app.models.foo.shared).to.equal(true); + }); + + }); + + describe('app.model(ModelCtor, config)', function() { + it('attaches the model to a datasource', function() { + app.dataSource('db', { connector: 'memory' }); + var TestModel = loopback.Model.extend('TestModel'); + // TestModel was most likely already defined in a different test, + // thus TestModel.dataSource may be already set + delete TestModel.dataSource; + + app.model(TestModel, { dataSource: 'db' }); + + expect(app.models.TestModel.dataSource).to.equal(app.dataSources.db); + }); }); describe('app.models', function() { @@ -544,4 +585,9 @@ describe('app', function() { expect(app.connectors.FOOBAR).to.equal(loopback.Memory); }); }); + + it('exposes loopback as a property', function() { + var app = loopback(); + expect(app.loopback).to.equal(loopback); + }); }); diff --git a/test/data-source.test.js b/test/data-source.test.js index eb0e1537..f5d9c6b7 100644 --- a/test/data-source.test.js +++ b/test/data-source.test.js @@ -23,7 +23,6 @@ describe('DataSource', function() { assert.isFunc(Color, 'destroyAll'); assert.isFunc(Color, 'count'); assert.isFunc(Color, 'include'); - assert.isFunc(Color, 'relationNameFor'); assert.isFunc(Color, 'hasMany'); assert.isFunc(Color, 'belongsTo'); assert.isFunc(Color, 'hasAndBelongsToMany'); @@ -53,7 +52,6 @@ describe('DataSource', function() { existsAndShared('destroyAll', false); existsAndShared('count', true); existsAndShared('include', false); - existsAndShared('relationNameFor', false); existsAndShared('hasMany', false); existsAndShared('belongsTo', false); existsAndShared('hasAndBelongsToMany', false); diff --git a/test/loopback.test.js b/test/loopback.test.js index 7988fdb4..bb0ef95a 100644 --- a/test/loopback.test.js +++ b/test/loopback.test.js @@ -1,4 +1,11 @@ describe('loopback', function() { + var nameCounter = 0; + var uniqueModelName; + + beforeEach(function() { + uniqueModelName = 'TestModel-' + (++nameCounter); + }); + describe('exports', function() { it('ValidationError', function() { expect(loopback.ValidationError).to.be.a('function') @@ -119,4 +126,95 @@ describe('loopback', function() { }); }); }); + + describe('loopback.createModel(config)', function() { + it('creates the model', function() { + var model = loopback.createModel({ + name: uniqueModelName + }); + + expect(model.prototype).to.be.instanceof(loopback.Model); + }); + + it('interprets extra first-level keys as options', function() { + var model = loopback.createModel({ + name: uniqueModelName, + base: 'User' + }); + + expect(model.prototype).to.be.instanceof(loopback.User); + }); + + it('prefers config.options.key over config.key', function() { + var model = loopback.createModel({ + name: uniqueModelName, + base: 'User', + options: { + base: 'Application' + } + }); + + expect(model.prototype).to.be.instanceof(loopback.Application); + }); + }); + + describe('loopback.configureModel(ModelCtor, config)', function() { + it('adds new relations', function() { + var model = loopback.Model.extend(uniqueModelName); + + loopback.configureModel(model, { + relations: { + owner: { + type: 'belongsTo', + model: 'User' + } + } + }); + + expect(model.settings.relations).to.have.property('owner'); + }); + + it('updates existing relations', function() { + var model = loopback.Model.extend(uniqueModelName, {}, { + relations: { + owner: { + type: 'belongsTo', + model: 'User' + } + } + }); + + loopback.configureModel(model, { + relations: { + owner: { + model: 'Application' + } + } + }); + + expect(model.settings.relations.owner).to.eql({ + type: 'belongsTo', + model: 'Application' + }); + }); + + it('updates relations before attaching to a dataSource', function() { + var db = loopback.createDataSource({ connector: loopback.Memory }); + var model = loopback.Model.extend(uniqueModelName); + + loopback.configureModel(model, { + dataSource: db, + relations: { + owner: { + type: 'belongsTo', + model: 'User' + } + } + }); + + var owner = model.prototype.owner; + expect(owner, 'model.prototype.owner').to.be.a('function'); + expect(owner._targetClass).to.equal('User'); + }); + }); }); diff --git a/test/relations.integration.js b/test/relations.integration.js index 0c3df8df..f4f667c1 100644 --- a/test/relations.integration.js +++ b/test/relations.integration.js @@ -5,6 +5,7 @@ var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-integration-app'); var app = require(path.join(SIMPLE_APP, 'app.js')); var assert = require('assert'); var expect = require('chai').expect; +var debug = require('debug')('loopback:test:relations.integration'); describe('relations - integration', function () { @@ -28,13 +29,19 @@ describe('relations - integration', function () { this.url = '/api/stores/' + this.store.id + '/widgets'; }); lt.describe.whenCalledRemotely('GET', '/api/stores/:id/widgets', function() { + it('should succeed with statusCode 200', function() { assert.equal(this.res.statusCode, 200); }); describe('widgets (response.body)', function() { beforeEach(function() { + debug('GET /api/stores/:id/widgets response: %s' + + '\nheaders: %j\nbody string: %s', + this.res.statusCode, + this.res.headers, + this.res.text); this.widgets = this.res.body; - this.widget = this.res.body[0]; + this.widget = this.res.body && this.res.body[0]; }); it('should be an array', function() { assert(Array.isArray(this.widgets)); diff --git a/test/role.test.js b/test/role.test.js index 3c43bc9e..9252743e 100644 --- a/test/role.test.js +++ b/test/role.test.js @@ -77,6 +77,40 @@ describe('role model', function () { }); + + it("should automatically generate role id", function () { + + User.create({name: 'Raymond', email: 'x@y.com', password: 'foobar'}, function (err, user) { + // console.log('User: ', user.id); + Role.create({name: 'userRole'}, function (err, role) { + assert(role.id); + role.principals.create({principalType: RoleMapping.USER, principalId: user.id}, function (err, p) { + assert(p.id); + assert.equal(p.roleId, role.id); + Role.find(function (err, roles) { + assert(!err); + assert.equal(roles.length, 1); + assert.equal(roles[0].name, 'userRole'); + }); + role.principals(function (err, principals) { + assert(!err); + // console.log(principals); + assert.equal(principals.length, 1); + assert.equal(principals[0].principalType, RoleMapping.USER); + assert.equal(principals[0].principalId, user.id); + }); + role.users(function (err, users) { + assert(!err); + assert.equal(users.length, 1); + assert.equal(users[0].principalType, RoleMapping.USER); + assert.equal(users[0].principalId, user.id); + }); + }); + }); + }); + + }); + it("should support getRoles() and isInRole()", function () { User.create({name: 'Raymond', email: 'x@y.com', password: 'foobar'}, function (err, user) { // console.log('User: ', user.id);