diff --git a/README.md b/README.md index 84e329cb5..61da0a875 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,42 @@ Fortune implements everything you need to get started with JSON API, with a few It does not come with any authentication or authorization, you should implement your own application-specific logic (see [keystore.js](//github.com/daliwali/fortune/blob/master/examples/keystore.js) for an example). +#### Custom Types + +Custom type defines its own schema and hooks and might be injected to any resource's schema side by side to standard data types and incorporates its data hooks into the resources' ones. +Usage example: + +``` + app.customType("date-timezone", { + date: String, + timeZone: String + }).beforeWrite([{ + name: 'datetz2db', + priority: -1, + init: function() { + return function(req, res) { + return DateTz.todb(this) + } + } + }]).afterRead([{ + name: 'datetz4db', + priority: 1000, + init: function() { + return function(req, res) { + return DateTz.fromdb(this); + } + } + }]) + + app.resource("schedule", { + ... + arrival: 'date-timezone' + ... + }) + ``` + + The datetz2db and datetz4db will be automatically attached to the parent resource hook chain. + ## Guide & Documentation The full guide and API documentation are located at [fortunejs.com](http://fortunejs.com/). diff --git a/lib/fortune.js b/lib/fortune.js index 41e6e819f..c9fc2ada1 100644 --- a/lib/fortune.js +++ b/lib/fortune.js @@ -189,7 +189,7 @@ Fortune.prototype.resources = function(req){ var _this = this; function filterHooks(hooks, time, type, resource, name){ - var ary = hooks[time][type]; + var ary = (hooks[time] || [])[type] || []; return _.reduce(_this._hookFilters, function(memo, filter){ return filter(memo, name, time.replace('_', ''), type, resource) }, ary); @@ -198,18 +198,19 @@ Fortune.prototype.resources = function(req){ var resources = _.map(_this._resources, function(md, name){ var schema = _.clone(md.schema); - _.each(schema, function(v,k){ - var vIsFunction = _.isFunction(v), - typeFn = vIsFunction ? v : v.type; - - if(typeFn){ - typeFn = typeFn.toString(); - typeFn = typeFn.substr('function '.length); - typeFn = typeFn.substr(0, typeFn.indexOf('(')); + var jsonFriendify = function(schema) { + _.each(schema, function(v,k){ + if(_.isFunction(v)){ + schema[k] = v.name; + } + else if(_.isObject(v)) { + schema[k] = jsonFriendify(v); + } + }); + return schema; + } - schema[k] = vIsFunction ? typeFn : _.extend({}, v, {type: typeFn}); - } - }); + schema = jsonFriendify(schema); var hooks = { beforeRead: _.map(filterHooks(md.hooks, '_before', 'read', {}, name), function(h){ return h.name}), @@ -252,6 +253,14 @@ Fortune.prototype._exposeResourceDefinitions = function() { }); }; +Fortune.prototype.customType = function(name, schema, options, schemaCallback) { + this._customTypes = this._customTypes || {} + this._customTypes[name] = _.extend({}, options, { name: name, schema: schema }) + this._resource = name; + + return this; +} + /** * Define a resource and setup routes simultaneously. A schema field may be either a native type, a plain object, or a string that refers to a related resource. * @@ -303,6 +312,20 @@ Fortune.prototype.resource = function(name, schema, options, schemaCallback) { return this; } + var customTypes = {}; + schema = _.mapValues(schema, function(value, key) { + if((typeof value).toLowerCase() === "string") { + + // Verify there is a custom type with the name + var customType = _this._customTypes[value]; + customTypes[key] = customType; + + // Custom type has Mixed schema instead + return customType.schema; + } + return value; + }); + this._resources = this._resources || {}; this._resources[name] = { actions: actionsObj, @@ -316,8 +339,47 @@ Fortune.prototype.resource = function(name, schema, options, schemaCallback) { authMethods: authMethods, validation: validation }; + plugins.init(this, this._resources[name]); + hooks.initGlobalHooks(_this._resources[name], _this.options); + + var customHooks = _.flatten(_.map(_.keys(customTypes), function(key) { + // Modify a names of the hooks to include field name they applied to + _.each(customTypes[key].hooks, function(whenHooks, when) { + _.each(whenHooks, function(actionHooks, action) { + _.each(actionHooks, function(actionHook) { + if(actionHook.name) { + actionHook.name = [key, actionHook.name].join('-'); + } + // Set highest priority + actionHook.priority = 1000; + + // Bind the handler to particular data type + var handler = actionHook.fn + if(handler) { + actionHook.fn = function(req, res) { + if(this[key]) { + this[key] = handler.call(this[key], req, res); + } + return this; + } + } + }); + }); + }) + return customTypes[key].hooks; + })); + + function mergeArray(to, from) { + if(_.isArray(to) && _.isArray(from)) { + return (to || []).concat(from); + } + } + + var mergedHooks = _.merge(_.merge.apply(this, customHooks, mergeArray) || {}, _this._resources[name].hooks, mergeArray); + _this._resources[name].hooks = mergedHooks; + //Register updates in queryParser. Should be called here to make sure that all resources are registered _this._exposeResourceDefinitions(); _this._querytree = querytree.init(this); @@ -338,15 +400,15 @@ Fortune.prototype.resource = function(name, schema, options, schemaCallback) { function () { schema = _this._preprocessSchema(schema); - // Store a copy of the input. - _this._schema[name] = _.clone(schema); try { + // Store a copy of the input. + _this._schema[name] = _.clone(schema); schema = _this.adapter.schema(name, schema, options, schemaCallback); // Pass any upsertKeys to the schema schema.upsertKeys = upsertKeys || []; - _this._route(name, _this.adapter.model(name, schema,modelOptions), _this._resources, inflect, _this._querytree, _this._metadataProviders); + _this._route(name, _this.adapter.model(name, schema, modelOptions), _this._resources, inflect, _this._querytree, _this._metadataProviders); _this._resourceInitialized(); } catch(error) { console.trace('There was an error loading the "' + name + '" resource. ' + error.stack || err); @@ -436,7 +498,6 @@ function GlobalHook(time, type){ } function Hook(time, type){ - return function(name, hooksArray, inlineConfig){ var that = this; if (this.options.throwOnLegacyTransforms && (_.isFunction(name) || _.isFunction(hooksArray))){ diff --git a/lib/hooks.js b/lib/hooks.js index bfcc02691..f0e6717c2 100644 --- a/lib/hooks.js +++ b/lib/hooks.js @@ -89,18 +89,23 @@ exports.addHook = function(name, hooks, stage, type, inlineConfig){ name.split(' ').forEach(function(resourceName) { hooks = normalize(hooks); - var resource = _this._resources[resourceName]; - if (!resource){ - return console.warn('You are trying to attach a hook to %s, ' + - 'that is not defined in this instance of fortune', resourceName); + + var resource; + if (!_this._resources || !_this._resources[resourceName]) { + if(!_this._customTypes || !_this._customTypes[resourceName]) { + return console.warn('You are trying to attach a hook to %s, ' + + 'that is not defined in this instance of fortune', resourceName); + } + else resource = _this._customTypes[resourceName]; } + else resource = _this._resources[resourceName]; + resource.hooks = resource.hooks || {}; resource.hooks[stage] = resource.hooks[stage] || {}; resource.hooks[stage][type] = resource.hooks[stage][type] || []; _.each(hooks, function(hook){ var hookOptions = getHookConfig(hook, resource, inlineConfig); var fn = hook.init(hookOptions, _this); - //fn._priority = hook.priority || 0; resource.hooks[stage][type].push(_.extend({ _priority: hook.priority || 0, name: hook.name, diff --git a/lib/route.js b/lib/route.js index 2a9f08153..8185c2d7d 100644 --- a/lib/route.js +++ b/lib/route.js @@ -175,7 +175,7 @@ function route(name, model, resources, inflect, querytree, metadataProviders) { instrumentor.createTracer( tracePrefix, function(resolve, reject){ //If no transforms found for this resource return without change var hooks = resources[model].hooks; - if (!hooks[time][type]) return resolve(resource); + if (!hooks[time] || !hooks[time][type]) return resolve(resource); resource = _.extend(resource, isNew); var transform = resource; _.each(filterHooks(hooks, time, type, resource, name), function(h){ diff --git a/test/app.js b/test/app.js index 2fb43e1d0..f0bdd56ee 100644 --- a/test/app.js +++ b/test/app.js @@ -19,7 +19,7 @@ var hooks = {}; if (req.query['fail' + type]) { console.log('Failing hook',type); _.defer(function() { - res.send(321); + res.sendStatus(321); }); if (req.query['fail' + type] === 'boolean') return false; @@ -66,14 +66,32 @@ module.exports = function(options, port, ioPort) { } }]); - app.beforeAll(hooks.beforeAll) .beforeAllRead(hooks.beforeAllRead) .beforeAllWrite(hooks.beforeAllWrite) .afterAll(hooks.afterAll) .afterAllRead(hooks.afterAllRead) .afterAllWrite(hooks.afterAllWrite) - + .customType("location", { + lat: Number, + lon: Number + }) + .beforeWrite([{ + name: 'todb', + init: function() { + return function(req, res){ + return this; + } + } + }]) + .afterRead([{ + name: 'fromdb', + init: function(){ + return function(req, res){ + return this; + } + } + }]) .resource('person', { name: String, official: String, @@ -97,6 +115,7 @@ module.exports = function(options, port, ioPort) { nestedField1: String, nestedField2: Number }], + location: 'location', upsertTest : String, _tenantId: String }, { @@ -195,7 +214,7 @@ module.exports = function(options, port, ioPort) { .before('person pet', function(req, res){ if (this.email === 'falsey@bool.com'){ - res.send(321); + res.sendStatus(321); return false; } return this; diff --git a/test/fortune-mongodb/mongodb.spec.js b/test/fortune-mongodb/mongodb.spec.js index 807dda0bb..2bcad0589 100644 --- a/test/fortune-mongodb/mongodb.spec.js +++ b/test/fortune-mongodb/mongodb.spec.js @@ -273,8 +273,10 @@ module.exports = function(options){ //hooks add their black magic here. //See what you have in fixtures + what beforeWrite hooks assign in addiction - var keys = Object.keys(docs[0]).length; - (keys).should.equal( expected ); + var keysLen = _.max(_.map(docs, function(doc) { + return Object.keys(docs[0]).length; + })); + (keysLen).should.equal( expected ); }); }); it('should not affect business id selection', function(){ @@ -373,69 +375,63 @@ module.exports = function(options){ done(); }); }); - it('should be able to filter date range: exclusive', function(done){ + it('should be able to filter date range: exclusive', function(){ var query = { birthday: { lt: '2000-02-02', gt: '1990-01-01' } }; - adapter.findMany('person', query) - .then(function(docs){ - (docs.length).should.equal(3); - done(); - }); + return adapter.findMany('person', query).then(function(docs){ + (docs.length).should.equal(3); + }); }); - it('should be able to filter date range: inclusive', function(done){ + it('should be able to filter date range: inclusive', function(){ var query = { birthday: { gte: '1995-01-01', lte: '2000-01-01' } }; - adapter.findMany('person', query) + return adapter.findMany('person', query) .then(function(docs){ (docs.length).should.equal(3); - done(); }); }); - it('should be able to filter number range: exclusive', function(done){ + it('should be able to filter number range: exclusive', function(){ var query = { appearances: { gt: 1934, lt: 4000 } }; - adapter.findMany('person', query) + return adapter.findMany('person', query) .then(function(docs){ (docs.length).should.equal(1); - done(); }); }); - it('should be able to filter number range: inclusive', function(done){ + it('should be able to filter number range: inclusive', function(){ var query = { appearances: { gte: 1934, lte: 3457 } }; - adapter.findMany('person', query) + return adapter.findMany('person', query) .then(function(docs){ (docs.length).should.equal(2); - done(); }); }); - it("should be tolerant to $in:undefined queries", function(done){ + it("should be tolerant to $in:undefined queries", function(){ var query = { '$in': undefined }; - - adapter.findMany("person", query).then(function(){ done(); }); + return adapter.findMany("person", query); }); - it("should be tolerant to $in:null queries", function(done){ + it("should be tolerant to $in:null queries", function(){ var query = { '$in': null }; - adapter.findMany("person", query).then(function(){ done(); }); + return adapter.findMany("person", query); }); it('should be able to run regex query with default options', function(){ @@ -457,33 +453,31 @@ module.exports = function(options){ }); }); }); - it('should be possible to specify custom options', function(done){ + it('should be possible to specify custom options', function(){ var query = { name: { regex: 'WALLY', options: 'i' } }; - adapter.findMany('person', query) + return adapter.findMany('person', query) .then(function(docs){ (docs.length).should.equal(1); (docs[0].name).should.equal('Wally'); - done(); }); }); - it('should treat empty regex as find all', function(done){ + it('should treat empty regex as find all', function(){ var query = { email: { regex: '' } }; - adapter.findMany('person', query) + return adapter.findMany('person', query) .then(function(docs){ (docs.length).should.equal(4); - done(); }); }); - it('should deeply parse nested $and, $or, or, and queries', function(done){ + it('should deeply parse nested $and, $or, or, and queries', function(){ var query = { $or: [{ or: [{ @@ -498,14 +492,12 @@ module.exports = function(options){ }] }] }; - adapter.findMany('person', query) + return adapter.findMany('person', query) .then(function(docs){ docs.length.should.equal(1); docs[0].name.should.equal('Wally'); - done(); }); }); }); }); - }; diff --git a/test/fortune/actions.js b/test/fortune/actions.js index 4801436bc..60b4fcd1e 100644 --- a/test/fortune/actions.js +++ b/test/fortune/actions.js @@ -10,13 +10,13 @@ module.exports = function(options){ ids = options.ids; }); it('should be able to define custom action on resource', function(done){ - request(baseUrl).post('/people/' + ids.people[0] + '/reset-password') + return request(baseUrl).post('/people/' + ids.people[0] + '/reset-password') .set('content-type', 'application/json') .send(JSON.stringify({})) .expect(200) .end(function(err, res){ should.not.exist(err); - res.text.should.equal('ok'); + res.text.should.equal('OK'); done(); }); }); @@ -28,7 +28,7 @@ module.exports = function(options){ .expect('reset-password', 'new password') .end(function(err, res){ should.not.exist(err); - res.text.should.equal('ok'); + res.text.should.equal('OK'); done(); }); }); @@ -39,7 +39,7 @@ module.exports = function(options){ .expect(200) .end(function(err, res){ should.not.exist(err); - res.text.should.equal('ok'); + res.text.should.equal('OK'); res.headers['reset-password-resource'].should.equal(ids.people[0]); done(); }); @@ -51,7 +51,7 @@ module.exports = function(options){ .expect(200) .end(function(err, res){ should.not.exist(err); - res.text.should.equal('ok'); + res.text.should.equal('OK'); //Nickname is generated dynamically by before and after hooks res.headers['reset-password-nickname'].should.equal('Super Dilbert!'); done(); @@ -65,7 +65,7 @@ module.exports = function(options){ .expect('reset-password-conf', 'set from init function') .end(function(err, res){ should.not.exist(err); - res.text.should.equal('ok'); + res.text.should.equal('OK'); res.headers['reset-password-nickname'].should.equal('Super Dilbert!'); done(); }); @@ -104,4 +104,4 @@ module.exports = function(options){ }) }) }); -}; \ No newline at end of file +}; diff --git a/test/runner.js b/test/runner.js index 0cc1e6c98..aaaa42dc2 100644 --- a/test/runner.js +++ b/test/runner.js @@ -68,7 +68,7 @@ describe('Fortune test runner', function(){ } person.pets = null; person.save(function() { - res.send(200); + res.sendStatus(200); }); }); diff --git a/test/testing-actions.js b/test/testing-actions.js index fcd87b3d9..c3ade6482 100644 --- a/test/testing-actions.js +++ b/test/testing-actions.js @@ -13,7 +13,7 @@ exports.peopleResetPassword = { res.set('reset-password-resource', this.id); res.set('reset-password-nickname', this.nickname); - res.send(200, 'ok'); + res.sendStatus(200); } } }; @@ -42,4 +42,4 @@ exports.genericAction = { res.send(200, 'ok'); }; } -} \ No newline at end of file +}