diff --git a/README.md b/README.md index fa268a29..368b7a54 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,5 @@ # LoopBack Workspace 3.0 -## Work In Progress - -Here is a list of TODOs before this is ready to be released: - -**Build** - - - [ ] Build the list of available connectors - - [ ] Build the strong-docs for internal reference - -**Angular Client** - - - [ ] Basic Smoke test - - [ ] Build the loopback-angular client via grunt / gulp - -**Project Structure** - - - [ ] Empty template - - [ ] Mobile Template - - [ ] SPA Template - - [ ] Smoke tests for project create + run (reuse the 2.0 approach) - -**Project Loading** - - - [x] Load a workspace into memory - -**Project Saving** - - - [x] Persist a workspace to disk - ## About The `loopback-workspace` module provides node.js and REST APIs for interacting diff --git a/app.js b/app.js index fa39dd55..5b683fc2 100644 --- a/app.js +++ b/app.js @@ -1,6 +1,7 @@ var loopback = require('loopback'); var path = require('path'); var app = module.exports = loopback(); +var env = app.get('env'); var boot = require('loopback-boot'); var started = new Date(); @@ -36,7 +37,9 @@ require('./connector'); */ app.use(loopback.favicon()); -app.use(loopback.logger(app.get('env') === 'development' ? 'dev' : 'default')); +if(env !== 'test') { + app.use(loopback.logger(env === 'development' ? 'dev' : 'default')); +} app.use(loopback.cookieParser(app.get('cookieSecret'))); app.use(loopback.token({model: app.models.accessToken})); app.use(loopback.methodOverride()); diff --git a/available-connectors.json b/available-connectors.json new file mode 100644 index 00000000..5e2a69af --- /dev/null +++ b/available-connectors.json @@ -0,0 +1,53 @@ +[ + { + "name": "memory", + "description": "In-memory db", + "supportedByStrongLoop": true + }, + { + "name": "mysql", + "description": "MySQL", + "supportedByStrongLoop": true + }, + { + "name": "postgresql", + "description": "PostgreSQL", + "supportedByStrongLoop": true + }, + { + "name": "oracle", + "description": "Oracle", + "supportedByStrongLoop": true + }, + { + "name": "mssql", + "description": "Microsoft SQL", + "supportedByStrongLoop": true + }, + { + "name": "mongodb", + "description": "MongoDB", + "supportedByStrongLoop": true + }, + { + "name": "soap", + "description": "SOAP webservices", + "supportedByStrongLoop": true + }, + { + "name": "rest", + "description": "REST services", + "supportedByStrongLoop": true + }, + + { + "name": "neo4j", + "description": "Neo4j", + "supportedByStrongLoop": false + }, + { + "name": "kafka", + "description": "Kafka", + "supportedByStrongLoop": false + } +] diff --git a/connector.js b/connector.js index e3724f73..244eae4b 100644 --- a/connector.js +++ b/connector.js @@ -26,6 +26,15 @@ connector.loadFromFile = function() { } loader = connector.loader = new EventEmitter(); + var done = function(err) { + if (err) + loader.emit('error', err); + else + loader.emit('complete'); + + connector.loader = null; + cb(err); + }; // reset the cache var cacheKeys = Object.keys(connector.cache); @@ -35,27 +44,24 @@ connector.loadFromFile = function() { }, {}); ConfigFile.findComponentFiles(function(err, components) { - if(err) return cb(err); + if(err) return done(err); var componentNames = Object.keys(components); - async.each(componentNames, function(component, cb) { + async.each(componentNames, function(component, next) { ComponentDefinition.loadIntoCache(cache, component, components, function(err) { if(err) { - loader.emit('error', err); - return cb(err); + return next(err); } // commit the cache connector.cache = cache; - connector.loader = null; - loader.emit('complete'); if(debug.enabled) { Object.keys(cache).forEach(function(model) { debug('setting cache %s => %j', model, Object.keys(cache[model])); }); } - cb(); + next(); }); - }, cb); + }, done); }); } @@ -67,6 +73,7 @@ connector.find = function(model) { var cb = args[args.length - 1]; connector.loadFromFile(function(err) { if(err) return cb(err); + debug('reading from cache %s => %j', model, Object.keys(connector.cache[model])); originalFind.apply(connector, args); }); } diff --git a/models.json b/models.json index 2a3b8c22..73b15922 100644 --- a/models.json +++ b/models.json @@ -11,7 +11,7 @@ "properties": { "name": {"id": true, "type": "string"} }, - "public": false, + "public": true, "dataSource": "db" }, "PackageDefinition": { @@ -122,7 +122,7 @@ "foreignKey": "fromModel" }, "accessControls": { - "embed": {"as": "array"}, + "embed": {"name": "acls", "as": "array"}, "type": "hasMany", "model": "ModelAccessControl", "foreignKey": "model" @@ -172,7 +172,7 @@ }, "ModelAccessControl": { "properties": { - "method": {"type": "string", "default": "ALL"}, + "property": {"type": "string"}, "route": {"type": "string"}, "principalId": {"type": "string"}, "principalType": {"type": "string"}, diff --git a/models/data-source-definition.js b/models/data-source-definition.js index 6eddec4c..e54a7fd0 100644 --- a/models/data-source-definition.js +++ b/models/data-source-definition.js @@ -64,6 +64,15 @@ DataSourceDefinition.prototype.discoverModelDefinition = function(name, options, this.toDataSource().discoverSchemas(name, options, cb); } +loopback.remoteMethod(DataSourceDefinition.prototype.discoverModelDefinition, { + http: {verb: 'get', path: '/definitions/:name'}, + accepts: [ + { arg: 'name', type: 'string', http: { source: 'path' } }, + { arg: 'options', type: 'object' } + ], + returns: { arg: 'definition', type: 'object' } +}); + /** * Get a list of table / collection names, owners and types. * @@ -84,6 +93,12 @@ DataSourceDefinition.prototype.getSchema = function(options, cb) { this.toDataSource().discoverModelDefinitions(options, cb); } +loopback.remoteMethod(DataSourceDefinition.prototype.getSchema, { + http: {verb: 'get', path: '/schema'}, + accepts: { arg: 'options', type: 'object' }, + returns: { arg: 'schema', type: 'object' } +}); + /** * Run a migration on the data source. Creates indexes, tables, collections, etc. * @@ -97,6 +112,8 @@ DataSourceDefinition.prototype.automigrate = function() { } +loopback.remoteMethod(DataSourceDefinition.prototype.automigrate); + /** * Update existing tables / collections. * @@ -108,6 +125,8 @@ DataSourceDefinition.prototype.autoupdate = function() { } +loopback.remoteMethod(DataSourceDefinition.prototype.autoupdate); + /** * Create a `loopback.DataSource` object from the `DataSourceDefinition`. * diff --git a/models/definition.js b/models/definition.js index 09409eed..1613cb65 100644 --- a/models/definition.js +++ b/models/definition.js @@ -1,6 +1,7 @@ var loopback = require('loopback'); var path = require('path'); var app = require('../app'); +var debug = require('debug')('workspace:definition'); var ConfigFile = app.models.ConfigFile; /** @@ -94,7 +95,7 @@ Definition.getEmbededRelations = function() { results.push({ embed: relation.embed, model: relation.model, - as: name, + as: relation.embed.name || name, type: relation.type, foreignKey: relation.foreignKey }); @@ -113,13 +114,17 @@ Definition.addRelatedToCache = function(cache, name, fileData) { if(Array.isArray(relatedData)) { relatedData.forEach(function(config) { - var id = config[relation.foreignKey]; + var id = config[relation.foreignKey] || config.id; + config[relation.foreignKey] = name; + debug('addRelatedToCache %s %s %j', relation.model, id, config); Entity.addToCache(cache, id, config); }); } else if(relatedData) { Object.keys(relatedData).forEach(function(id) { var config = relatedData[id]; config[Entity.dataSource.idName(Entity.modelName)] = id; + config[relation.foreignKey] = name; + debug('addRelatedToCache %s %s %j', relation.model, id, config); Entity.addToCache(cache, id, config); }); } diff --git a/models/model-access-control.js b/models/model-access-control.js index 4c08eea9..ab5a1118 100644 --- a/models/model-access-control.js +++ b/models/model-access-control.js @@ -1,5 +1,6 @@ var app = require('../app'); var ACL = require('loopback').ACL; +var Role = require('loopback').Role; /** * Represents an Access Control configuration. @@ -19,18 +20,19 @@ var ModelAccessControl = app.models.ModelAccessControl; * ```js * { * value: 'the value', // may be string or number - * humanized: 'the humanized value' + * name: 'a short name' * } * ``` */ ModelAccessControl.getAccessTypes = function(cb) { cb(null, [ - {value: ACL.READ, humanized: 'Read'}, - {value: ACL.WRITE, humanized: 'Write'}, - {value: ACL.EXECUTE, humanized: 'Execute'} + { name: 'All (match all types)', value: ACL.ALL }, + { name: 'Read', value: ACL.READ }, + { name: 'Write', value: ACL.WRITE }, + { name: 'Execute', value: ACL.EXECUTE }, ]); -} +}; /** * Get the available permission types. @@ -41,20 +43,19 @@ ModelAccessControl.getAccessTypes = function(cb) { * ```js * { * value: 'the value', // may be string or number - * humanized: 'the humanized value' + * name: 'a descriptive name' * } * ``` */ -ModelAccessControl.getPermissionTypes = function() { +ModelAccessControl.getPermissionTypes = function(cb) { cb(null, [ - {value: ACL.DEFAULT, humanized: 'Default'}, - {value: ACL.ALLOW, humanized: 'Allow'}, - {value: ACL.ALARM, humanized: 'Alarm'}, - {value: ACL.AUDIT, humanized: 'Audit'}, - {value: ACL.DENY, humanized: 'Deny'} + { name: 'Explicitly grant access', value: ACL.ALLOW }, + { name: 'Explicitly deny access', value: ACL.DENY }, + { name: 'Generate an alarm of the access', value: ACL.ALARM }, + { name: 'Log the access', value: ACL.AUDIT }, ]); -} +}; /** * Get the available principal types. @@ -65,16 +66,39 @@ ModelAccessControl.getPermissionTypes = function() { * ```js * { * value: 'the value', // may be string or number - * humanized: 'the humanized value' + * name: 'a descriptive name' * } * ``` */ -ModelAccessControl.getPrincipalTypes = function() { +ModelAccessControl.getPrincipalTypes = function(cb) { cb(null, [ - {value: ACL.USER, humanized: 'User'}, - {value: ACL.APP, humanized: 'App'}, - {value: ACL.ROLE, humanized: 'Role'}, - {value: ACL.SCOPE, humanized: 'Scope'} + { name: 'User', value: ACL.USER }, + { name: 'App', value: ACL.APP }, + { name: 'Role', value: ACL.ROLE }, + { name: 'Scope', value: ACL.SCOPE }, ]); -} +}; + +/** + * Get the available built-in roles. + * + * @callback {Function} callback + * @param {Error} err + * @param {Array} types An array of objects with the following format: + * ```js + * { + * value: 'the value', // may be string or number + * name: 'a descriptive name' + * } + * ``` + */ +ModelAccessControl.getBuiltinRoles = function(cb) { + cb(null, [ + { name: 'All users', value: Role.EVERYONE }, + { name: 'Any unauthenticated user', value: Role.UNAUTHENTICATED }, + { name: 'Any authenticated user', value: Role.AUTHENTICATED }, + { name: 'Any user related to the object', value: Role.RELATED }, + { name: 'The user owning the object', value: Role.OWNER }, + ]); +}; diff --git a/models/model-definition.js b/models/model-definition.js index 434a6e69..09d384b9 100644 --- a/models/model-definition.js +++ b/models/model-definition.js @@ -28,6 +28,8 @@ ModelDefinition.getConfigData = function(cache, modelDef) { var configData = {}; var relations = this.getEmbededRelations(); + configData.name = modelDef.name; + relations.forEach(function(relation) { var relatedData = getRelated(cache, modelDef.name, relation); configData[relation.as] = formatRelatedData(relation, relatedData); diff --git a/models/model-property.js b/models/model-property.js index 614e6039..875ff5f5 100644 --- a/models/model-property.js +++ b/models/model-property.js @@ -8,3 +8,18 @@ var app = require('../app'); */ var ModelProperty = app.models.ModelProperty; + +/** + * List of built-in types that can be used for `ModelProperty.type`. + * @type {string[]} + */ +ModelProperty.availableTypes = [ + 'string', + 'number', + 'boolean', + 'object', + 'array', + 'date', + 'buffer', + 'geopoint' +]; diff --git a/models/workspace.js b/models/workspace.js index fac76caf..94d965aa 100644 --- a/models/workspace.js +++ b/models/workspace.js @@ -14,6 +14,7 @@ var ViewDefinition = app.models.ViewDefinition; var TEMPLATE_DIR = path.join(__dirname, '..', 'templates'); var DEFAULT_TEMPLATE = 'api-server'; var debug = require('debug')('workspace'); +var loopback = require('loopback'); /** * Groups related LoopBack applications. @@ -35,6 +36,10 @@ Workspace.getAvailableTemplates = function(cb) { fs.readdir(TEMPLATE_DIR, cb); } +loopback.remoteMethod(Workspace.getAvailableTemplates, { + http: {verb: 'get', path: '/component-templates'} +}); + Workspace.addComponent = function(options, cb) { var template; var templateName = options.template || DEFAULT_TEMPLATE; @@ -149,6 +154,11 @@ Workspace.addComponent = function(options, cb) { async.parallel(steps, cb); } +loopback.remoteMethod(Workspace.addComponent, { + http: {verb: 'post', path: '/component'}, + accepts: {arg: 'options', type: 'object', http: {source: 'body'}} +}); + /** * In the attached `dataSource`, create a set of app definitions and * corresponding workspace entities using the given template. @@ -165,3 +175,35 @@ Workspace.createFromTemplate = function(templateName, name, cb) { template: templateName }, cb); } + +loopback.remoteMethod(Workspace.createFromTemplate, { + http: {verb: 'post', path: '/'}, + accepts: [{ + arg: 'templateName', type: 'string', http: {source: 'body'} + }, { + arg: 'name', type: 'string', http: {source: 'body'} + }] +}); + +/** + * @typedef {{name, description,supportedByStrongLoop}} ConnectorMeta + */ + +/** + * @type {Array.} + * @internal + */ +var staticConnectorList = require('../available-connectors'); + +/** + * List of connectors available on npm. + * @param {function(Error=,Array.=)} cb + */ +Workspace.listAvailableConnectors = function(cb) { + cb(null, staticConnectorList); +}; + +loopback.remoteMethod(Workspace.listAvailableConnectors, { + http: {verb: 'get', path: '/connectors'}, + returns: {arg: 'connectors', type: 'array', root: true} +}); diff --git a/package.json b/package.json index 1f6b6e19..eeb64cfb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,9 @@ { "version": "3.0.0", "main": "app.js", - "scripts": {}, + "scripts": { + "test": "mocha" + }, "dependencies": { "loopback": "1.x >=1.7.0", "async": "^0.9.0", @@ -14,18 +16,20 @@ "underscore.string": "~2.3.3", "grunt": "~0.4.5", "grunt-loopback-angular": "~1.1.0", - "ncp": "^0.5.1" + "ncp": "^0.5.1", + "supertest": "~0.13.0" }, "optionalDependencies": { "loopback-explorer": "~1.1.0" }, "name": "loopback-workspace", "devDependencies": { - "loopback-testing": "~0.1.5", + "better-stack-traces": "^1.0.1", "chai": "~1.9.1", "grunt": "~0.4.5", - "grunt-loopback-angular": "~1.1.0", "grunt-docular": "~0.1.2", - "better-stack-traces": "^1.0.1" + "grunt-loopback-angular": "~1.1.0", + "loopback-testing": "^0.2.0", + "mocha": "^1.20.1" } } diff --git a/test/model-definition.js b/test/model-definition.js index 4851ce49..c78f0efb 100644 --- a/test/model-definition.js +++ b/test/model-definition.js @@ -1,6 +1,8 @@ var app = require('../app'); var ModelDefinition = app.models.ModelDefinition; +var ModelAccessControl = app.models.ModelAccessControl; var TestDataBuilder = require('loopback-testing').TestDataBuilder; +var ref = TestDataBuilder.ref; var ConfigFile = app.models.ConfigFile; describe('ModelDefinition', function() { @@ -70,4 +72,49 @@ describe('ModelDefinition', function() { }} } }); + + describe('ModelDefinition.getConfigData(cache, modelDef)', function() { + beforeEach(givenEmptyWorkspace); + + before(function() { + Object.defineProperty(this, 'cache', { + get: function() { + return app.dataSources.db.connector.cache; + } + }); + }); + + it('includes `name` property', function(done) { + new TestDataBuilder() + .define('model', ModelDefinition, { + name: 'test-model' + }) + .buildTo(this, function(err) { + if (err) return done(err); + var data = ModelDefinition.getConfigData(this.cache, this.model); + expect(data).to.have.property('name', 'test-model'); + done(); + }.bind(this)); + }); + + it('includes access-control configuration', function(done) { + new TestDataBuilder() + .define('model', ModelDefinition, { + name: 'Car', + componentName: '.' + }) + .define('aclx', ModelAccessControl, { + method: 'ALL', + model: ref('model.name') + }) + .buildTo(this, function(err) { + if (err) return done(err); + var data = ModelDefinition.getConfigData(this.cache, this.model); + expect(data).to.have.property('acls'); + expect(data.acls, 'acls').to.have.length(1); + expect(data.acls[0], 'acls[0]').to.have.property('method', 'ALL'); + done(); + }.bind(this)); + }); + }); }); diff --git a/test/model-property.js b/test/model-property.js index 9b098e96..5a41b9b5 100644 --- a/test/model-property.js +++ b/test/model-property.js @@ -70,4 +70,19 @@ describe('ModelProperty', function() { expect(properties[this.propertyName]).to.eql({type: 'Boolean'}); }); }); + + describe('modelProperty.load()', function() { + it('should restore model relation', function(done) { + // every query triggers a reload + ModelProperty.all(function(err, list) { + if (err) return done(err); + expect(list[0].toObject()).to.eql(new ModelProperty({ + name: this.propertyName, + type: 'String', + modelName: 'user' + }).toObject()); + done(); + }.bind(this)); + }); + }); }); diff --git a/test/rest.js b/test/rest.js new file mode 100644 index 00000000..74cd96d5 --- /dev/null +++ b/test/rest.js @@ -0,0 +1,69 @@ +var app = require('../app'); +var request = require('supertest'); + +describe('REST API', function () { + beforeEach(givenEmptySandbox); + + describe('/workspaces', function () { + describe('POST /workspaces', function () { + beforeEach(function(done) { + request(app) + .post('/api/workspaces') + .set('Content-Type', 'application/json') + .send({ + templateName: 'api-server', + name: 'sandbox' + }) + .end(done); + }); + it('should add a component from a template', function (done) { + app.models.ComponentDefinition.find(function(err, defs) { + var names = toNames(defs); + expect(names).to.contain('rest'); + expect(names).to.contain('.'); + expect(names).to.contain('server'); + done(); + }); + }); + }); + + describe('POST /workspaces/component', function () { + beforeEach(function(done) { + request(app) + .post('/api/workspaces/component') + .set('Content-Type', 'application/json') + .send({ + template: 'rest' + }) + .end(done); + }); + it('should add a component from a template', function (done) { + app.models.ComponentDefinition.find(function(err, defs) { + expect(toNames(defs)).to.contain('rest'); + done(); + }); + }); + }); + describe('POST /workspaces/connectors', function () { + beforeEach(function(done) { + this.req = request(app) + .get('/api/workspaces/connectors') + .set('Accepts', 'application/json') + .end(done); + }); + it('should return a list of connectors', function () { + var connectors = toNames(this.req.res.body); + expect(connectors).to.contain('memory'); + expect(connectors).to.contain('mysql'); + expect(connectors).to.contain('postgresql'); + expect(connectors).to.contain('oracle'); + expect(connectors).to.contain('mssql'); + expect(connectors).to.contain('mongodb'); + expect(connectors).to.contain('soap'); + expect(connectors).to.contain('rest'); + expect(connectors).to.contain('neo4j'); + expect(connectors).to.contain('kafka'); + }); + }); + }); +}); diff --git a/test/support.js b/test/support.js index 8deba639..66c59c9d 100644 --- a/test/support.js +++ b/test/support.js @@ -3,6 +3,7 @@ var async = require('async'); var fs = require('fs-extra'); var path = require('path'); expect = require('chai').expect; +process.env.NODE_ENV = 'test'; var workspace = require('../app'); var models = workspace.models; var ConfigFile = models.ConfigFile; diff --git a/test/workspace.js b/test/workspace.js index 8e965db9..c8513f60 100644 --- a/test/workspace.js +++ b/test/workspace.js @@ -51,4 +51,19 @@ describe('Workspace', function() { describe('Workspace.listUseableConnectors(cb)', function () { it('should return a list of connectors in package.json'); }); + + + describe('project.listAvailableConnectors(cb)', function() { + before(function(done) { + Workspace.listAvailableConnectors(function(err, list) { + this.connectors = list; + done(err); + }.bind(this)); + }); + + it('should include Memory connector', function() { + var names = this.connectors.map(function(it) { return it.name; }); + expect(names).to.contain('memory'); + }); + }); });