diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab8d2d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.sublime-* diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..e69de29 diff --git a/index.js b/index.js new file mode 100644 index 0000000..213af0b --- /dev/null +++ b/index.js @@ -0,0 +1,274 @@ + +/** + * DEPENDENCIES + */ +var glob = require('glob'), + path = require('path'), + _ = require('underscore.string'); + +/** + * CONSTANTS + */ +var MAGIC_ACTIONS = { + "create": {method: "post", args: null}, + "read": {method: "get", args: [":id"]}, + "update": {method: "put", args: [":id"]}, + "del": {method: "delete", args: [":id"]}, + "search": {method: "get", args: null}, + "index": {method: "get", args: null}, + "list": {method: "get", args: null} + }; + +/** + * PUBLIC API + */ + +/** + * Attach an express.js app + * + * @param {Function} app + */ +exports.bind = function (app) { + this.app = app; +}; + +/** + * Automatic router middleware + * + * @param {Function} app An express app + */ +exports.load = function (app, options) { + var self = this; + + if (typeof app === 'object' && typeof options !== 'object') { + options = app; + app = null; + } + + if (app) { + this.bind(app); + } + + options = options || {}; + + if (!options.pattern) { + throw new Error('You must specify a controller matching pattern! E.g. "controllers/*.js"'); + } + + // Make this sync because we want this to be executed where we intend: + glob.sync(options.pattern).forEach(function (file) { + var controller = require(file), + controllerName = getControllerName(file, options.nameRegExp), + mountPoint = stripUnwantedSlashes(options.prefix) + '/' + (controllerName.toLowerCase() !== 'home' ? controllerName : ''); + + self.mount(mountPoint, controller); + }); +}; + +/** + * Mount a given controller + * + * @param {String} name Used as mount point + * @param {Object} controller + */ +exports.mount = function (name, controller) { + if (!this.app) { + throw new Error('An Express.js app must be bound to exctrl before mounting a controller!'); + } + + if (typeof name === 'object' && typeof name.name === 'string') { + controller = name; + name = controller.name; + } + + var app = this.app; + + Object.keys(controller).forEach(function (action) { + + if (action === 'name' && controller[action] === name) { + return; + } + + var magic = MAGIC_ACTIONS[action] || {}, + route = { + method: magic.method || getMethod(action) || "get", + url: [], + args: magic.args || [], + handler: controller[action] + }; + + if (name) { + route.url.push(stripUnwantedSlashes(name)); + } + + if (action === 'init') { + controller[action](app, route.url.join('/')); + return; + } + + if (!magic.method) { + // Custom actions (i.e. not magic) needs its name without HTTP method added to the url: + var urlPart = stripUnwantedSlashes(getUrlPart(action)); + if (urlPart) { + route.url.push(urlPart); + } + } + + // Is the action an array? + if (Array.isArray(route.handler)) { + if (route.handler.length > 1) { + // All preceeding elements in the array are url params/chunks/arguments e.g. ":id": + route.args = route.handler.splice(0, route.handler.length - 1); + } + // The last element in the array is the real handler: + route.handler = route.handler.pop(); + } + + if (typeof route.handler !== 'function') { + throw new Error('Bad handler type for action: ' + name + ':' + action + ', expected: "function", but was: "' + typeof(route.handler) + '"'); + } + + // Add the url params/chunks/arguments to the route.url: + arrayAdd(route.url, getUrlChunks(route.args)); + + if (app.get('env') === 'development') { + console.log('CONTROLLER-ACTION:ADD', route.method.toUpperCase() + ' ' + getUrl(route.url)); + } + + // Add the route to app: + app[route.method].apply(app, + [getUrl(route.url)] + .concat(getMiddlewares(route.args)) + .concat(route.handler.bind(controller)) + ); + }); +}; + +/** + * PRIVATE API + */ + +/** + * Add one array's elements to another + * + * @param {Array} dest + * @param {Array} src + */ +function arrayAdd (dest, src) { + dest.push.apply(dest, src); +} + +/** + * Get controller name + * + * Gets the controller name from a filename + * If `extractorRegExp` is provided and it matches + * the filename, the first match group will be returned. + * Otherwise the filename without extension will be returned. + * + * Example 1: + * file: /var/tmp/super.controller.js + * extractorRegExp: /([^\/\\]+).controller.js$/ + * Returns: "super" + * + * Example 2: + * file: /var/tmp/controllers/super.js + * extractorRegExp: NULL + * Returns: "super" + * + * @param {String} file + * @param {RegExp} extractorRegExp + * @returns {String} + */ +function getControllerName (file, extractorRegExp) { + var name = extractorRegExp ? file.match(extractorRegExp)[1] : null; + return name || path.basename(file).slice(0, -path.extname(file).length); +} + +/** + * Get an URL from an array of chunks + * + * Joins the chunks with "/" and prepends it as well + * + * @param {Array} urlChunks + * @returns {String} + */ +function getUrl (urlChunks) { + return '/' + urlChunks.join('/'); +} + +/** + * Get all middlewares + * + * Returns all middlewares, i.e. functions, in an array + * + * @param {Array} arr + * @returns {Array} + */ +function getMiddlewares (arr) { + return arr.filter(function (e) { + return typeof e === 'function'; + }); +} + +/** + * Get all url chunks + * + * Returns all url chunks, i.e. not functions, in an array + * + * @param {Array} arr + * @returns {Array} + */ +function getUrlChunks (arr) { + return arr.filter(function (e) { + return typeof e !== 'function'; + }); +} + +/** + * Removes leading and trailing slashes in url + * + * @param {String} url + * @returns {String} + */ +function stripUnwantedSlashes (url) { + return _.trim(url || '', '/'); +} + +/** + * Get HTTP method from action name + * + * Expects action name to be camelCased, e.g: + * - postThing -> post + * - getSuperThingy -> get + * - weirdAction -> NULL + * + * @param {String} action + * @returns {String} + */ +function getMethod (action) { + var method = action.split(/[^a-z]/)[0]; + if (['post', 'head', 'get', 'delete', 'put'].indexOf(method) >= 0) { + return method; + } + return null; +} + +/** + * Get url part from action name + * + * Expects action name to be camelCased, e.g: + * - postThing -> thing + * - getSuperThingy -> super-thingy + * - myAction -> my-action + * + * @param {String} action + * @returns {String} + */ +function getUrlPart (action) { + var method = getMethod(action); + if (method) { + action = action.slice(method.length); + } + return _.ltrim(_.dasherize(action), '-'); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..22c6f58 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "exctrl", + "version": "0.1.0", + "description": "Flexible controllers for express.js apps", + "main": "index.js", + "scripts": { + "test": "node_modules/.bin/mocha -R spec tests" + }, + "repository": { + "type": "git", + "url": "https://github.com/klei-dev/exctrl" + }, + "keywords": [ + "controller", + "express", + "router" + ], + "author": "Joakim Bengtson ", + "license": "MIT", + "bugs": { + "url": "https://github.com/klei-dev/exctrl/issues" + }, + "devDependencies": { + "mocha": "~1.14.0", + "chai": "~1.8.1" + }, + "dependencies": { + "glob": "~3.2.7", + "underscore.string": "~2.3.3" + } +} diff --git a/tests/.jshintrc b/tests/.jshintrc new file mode 100644 index 0000000..d1de4ee --- /dev/null +++ b/tests/.jshintrc @@ -0,0 +1,19 @@ +{ + "node": true, + "esnext": true, + "globals": { + "describe": false, + "it": false, + "before": false, + "beforeEach": false, + "after": false, + "afterEach": false + }, + "globalstrict": false, + "quotmark": false, + "smarttabs": true, + "trailing": true, + "undef": true, + "unused": true, + "strict": false +} diff --git a/tests/controllers/home.js b/tests/controllers/home.js new file mode 100644 index 0000000..4d1caae --- /dev/null +++ b/tests/controllers/home.js @@ -0,0 +1,4 @@ + +exports.index = function (req, res) { + res.send('index'); +}; diff --git a/tests/controllers/user.js b/tests/controllers/user.js new file mode 100644 index 0000000..2f6f5d5 --- /dev/null +++ b/tests/controllers/user.js @@ -0,0 +1,8 @@ + +exports.read = function (req, res) { + res.send('a user'); +}; + +exports.list = function (req, res) { + res.send('all users'); +}; diff --git a/tests/exctrl.js b/tests/exctrl.js new file mode 100644 index 0000000..980cab5 --- /dev/null +++ b/tests/exctrl.js @@ -0,0 +1,311 @@ + +var chai = require('chai'), + expect = chai.expect, + argsToArray = Function.prototype.call.bind(Array.prototype.slice), + exctrl = require('../.'); + +describe('exctrl', function () { + var mockApp = null; + + beforeEach(function () { + mockApp = { + routes: [], + add: function (method, args) { + args = argsToArray(args); + var url = args.shift(), + handler = args.pop(), + middlewares = args; + this.routes.push({method: method, url: url, middlewares: middlewares, handler: handler}); + }, + get: function () { + if (arguments.length === 1 && arguments[0] === 'env') { + return 'test'; + } + this.add('get', arguments); + }, + post: function () { + this.add('post', arguments); + }, + put: function () { + this.add('put', arguments); + }, + delete: function () { + this.add('delete', arguments); + }, + head: function () { + this.add('head', arguments); + } + }; + }); + + describe('mount', function () { + it('should use controller.name as mount point, if no name is given', function () { + var controller = { + name: 'user', + get: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].url).to.equal('/user'); + }); + + it('should use given name as mount point', function () { + var controller = { + get: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount('user', controller); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].url).to.equal('/user'); + }); + + it('should mount actions with http method names directly to mount point, with correct method', function () { + var controller = { + name: 'user', + get: function () {}, + post: function () {}, + put: function () {}, + delete: function () {}, + head: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(5); + expect(mockApp.routes[0].method).to.equal('get'); + expect(mockApp.routes[1].method).to.equal('post'); + expect(mockApp.routes[2].method).to.equal('put'); + expect(mockApp.routes[3].method).to.equal('delete'); + expect(mockApp.routes[4].method).to.equal('head'); + expect(mockApp.routes[0].url).to.equal('/user'); + expect(mockApp.routes[1].url).to.equal('/user'); + expect(mockApp.routes[2].url).to.equal('/user'); + expect(mockApp.routes[3].url).to.equal('/user'); + expect(mockApp.routes[4].url).to.equal('/user'); + }); + + it('should mount CRUD named actions as their http equivalents with params', function () { + var controller = { + name: 'user', + create: function () {}, + read: function () {}, + update: function () {}, + del: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(4); + expect(mockApp.routes[0].method).to.equal('post'); + expect(mockApp.routes[1].method).to.equal('get'); + expect(mockApp.routes[2].method).to.equal('put'); + expect(mockApp.routes[3].method).to.equal('delete'); + expect(mockApp.routes[0].url).to.equal('/user'); + expect(mockApp.routes[1].url).to.equal('/user/:id'); + expect(mockApp.routes[2].url).to.equal('/user/:id'); + expect(mockApp.routes[3].url).to.equal('/user/:id'); + }); + + it('should mount `index` action as a http get route, without params', function () { + var controller = { + name: 'user', + index: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].method).to.equal('get'); + expect(mockApp.routes[0].url).to.equal('/user'); + }); + + it('should mount `search` action as a http get route, without params', function () { + var controller = { + name: 'user', + search: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].method).to.equal('get'); + expect(mockApp.routes[0].url).to.equal('/user'); + }); + + it('should mount `list` action as a http get route, without params', function () { + var controller = { + name: 'user', + list: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].method).to.equal('get'); + expect(mockApp.routes[0].url).to.equal('/user'); + }); + + it('should mount any other action, not starting with a http method name, as a http get route at the mount point', function () { + var controller = { + name: 'user', + friend: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].method).to.equal('get'); + expect(mockApp.routes[0].url).to.equal('/user/friend'); + }); + + it('should mount an action starting with a http method name as a route with correct http method at the mount point', function () { + var controller = { + name: 'user', + postFriend: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].method).to.equal('post'); + expect(mockApp.routes[0].url).to.equal('/user/friend'); + }); + + it('should dasherize the URL part of the action name', function () { + var controller = { + name: 'user', + postFriendsAndOthers: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].method).to.equal('post'); + expect(mockApp.routes[0].url).to.equal('/user/friends-and-others'); + }); + + it('should handle actions given with array syntax', function () { + var controller = { + name: 'user', + post: [function () {}] + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].method).to.equal('post'); + expect(mockApp.routes[0].url).to.equal('/user'); + expect(mockApp.routes[0].handler).to.be.a('function'); + }); + + it('should add any provided parameters or URL chunks to actions given with array syntax', function () { + var controller = { + name: 'user', + postFriend: [':name', function () {}], + get: [':id', 'friends', function () {}] + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(2); + expect(mockApp.routes[0].method).to.equal('post'); + expect(mockApp.routes[1].method).to.equal('get'); + expect(mockApp.routes[0].url).to.equal('/user/friend/:name'); + expect(mockApp.routes[1].url).to.equal('/user/:id/friends'); + expect(mockApp.routes[0].handler).to.be.a('function'); + expect(mockApp.routes[1].handler).to.be.a('function'); + }); + + it('should add any provided middleware to actions given with array syntax', function () { + var middleware = function () {}; + var controller = { + name: 'user', + get: [':id', middleware, 'friends', function () {}] + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].method).to.equal('get'); + expect(mockApp.routes[0].url).to.equal('/user/:id/friends'); + expect(mockApp.routes[0].handler).to.be.a('function'); + expect(mockApp.routes[0].middlewares).to.have.length(1); + expect(mockApp.routes[0].middlewares[0]).to.equal(middleware); + }); + + it('should not modify `this` for actions with array syntax', function () { + var controller = { + name: 'user', + get: [':id', function () { + return this.post(); + }], + post: function () { + return 'post'; + } + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(2); + expect(mockApp.routes[0].method).to.equal('get'); + expect(mockApp.routes[1].method).to.equal('post'); + expect(mockApp.routes[0].handler).to.be.a('function'); + expect(mockApp.routes[1].handler).to.be.a('function'); + expect(mockApp.routes[0].handler()).to.equal('post'); + }); + + it('should handle more advanced action names', function () { + var controller = { + name: 'user', + "get/:id/friends": function () {}, + "get/:id/groups": function () {}, + "post/:id/friends": function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(3); + expect(mockApp.routes[0].method).to.equal('get'); + expect(mockApp.routes[1].method).to.equal('get'); + expect(mockApp.routes[2].method).to.equal('post'); + expect(mockApp.routes[0].url).to.equal('/user/:id/friends'); + expect(mockApp.routes[1].url).to.equal('/user/:id/groups'); + expect(mockApp.routes[2].url).to.equal('/user/:id/friends'); + }); + + it('should handle underscored action names', function () { + var controller = { + name: 'user', + close_friends: function () {}, + post_friend: function () {} + }; + exctrl.bind(mockApp); + exctrl.mount(controller); + expect(mockApp.routes).to.have.length(2); + expect(mockApp.routes[0].method).to.equal('get'); + expect(mockApp.routes[1].method).to.equal('post'); + expect(mockApp.routes[0].url).to.equal('/user/close-friends'); + expect(mockApp.routes[1].url).to.equal('/user/friend'); + }); + }); + + describe('load', function () { + it('should mount all controllers matching given pattern', function () { + exctrl.bind(mockApp); + exctrl.load({pattern: __dirname + '/controllers/*.js'}); + expect(mockApp.routes).to.have.length(3); + }); + + it('should mount `home` controller as root', function () { + exctrl.bind(mockApp); + exctrl.load({pattern: __dirname + '/controllers/*.js'}); + expect(mockApp.routes).to.have.length(3); + expect(mockApp.routes[0].url).to.equal('/'); + }); + + it('should mount all controllers matching given pattern at given prefix', function () { + exctrl.bind(mockApp); + exctrl.load({pattern: __dirname + '/controllers/*.js', prefix: 'api'}); + expect(mockApp.routes).to.have.length(3); + expect(mockApp.routes[0].url).to.equal('/api'); + expect(mockApp.routes[1].url).to.equal('/api/user/:id'); + expect(mockApp.routes[2].url).to.equal('/api/user'); + }); + + it('should be able to extract controller name from filename via provided regexp', function () { + exctrl.bind(mockApp); + exctrl.load({pattern: __dirname + '/other_controllers/*.js', nameRegExp: /([^\/\\]+).ctrl.js$/, prefix: 'api'}); + expect(mockApp.routes).to.have.length(1); + expect(mockApp.routes[0].url).to.equal('/api'); + }); + }); + +}); diff --git a/tests/other_controllers/home.ctrl.js b/tests/other_controllers/home.ctrl.js new file mode 100644 index 0000000..4d1caae --- /dev/null +++ b/tests/other_controllers/home.ctrl.js @@ -0,0 +1,4 @@ + +exports.index = function (req, res) { + res.send('index'); +};