├── .gitignore ├── LICENSE ├── README-v0.md ├── README.md ├── index.js ├── lib ├── api.js ├── client.js ├── definition.js ├── middleware.js ├── module-builder.js └── swagger-builder.js ├── package.json └── test ├── middleware.js ├── modules-files ├── hello.txt ├── jailbreak.txt └── via_api.txt ├── modules-server.js ├── modules.js ├── petstore-server.js └── petstore.js /.gitignore: -------------------------------------------------------------------------------- 1 | creds/ 2 | node_modules/ 3 | server.js 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (MIT License) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | 'Software'), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README-v0.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | ```npm install jammin``` 3 | 4 | **Note: Jammin is still in development. The API is not stable.** 5 | 6 | ## About 7 | Jammin is the fastest way to build REST APIs in NodeJS. It consists of: 8 | * A light-weight wrapper around [Mongoose](http://mongoosejs.com/) to expose database operations 9 | * A light-weight module wrapper to expose functions as API endpoints 10 | 11 | Jammin is built for [Express](http://expressjs.com/) and is fully extensible via **middleware** to support things like authentication, sanitization, and resource ownership. 12 | 13 | In addition to performing database CRUD, Jammin can bridge function calls over HTTP. If you have a node module that communicates via JSON-serializable data, Jammin allows you to ```require()``` that module from a remote NodeJS client. See the Modules section for an example. 14 | 15 | Jammin can also serve a [Swagger](http://swagger.io) specification, allowing your API to link into tools like [Swagger UI](http://petstore.swagger.io/) and [LucyBot](https://lucybot.com) 16 | 17 | ## Usage 18 | 19 | ### Database Operations 20 | Use ```API.define()``` to create Mongoose models. You can attach HTTP routes to each model that will use ```req.params``` and ```req.query``` to query the database and ```req.body``` to update it. 21 | ```js 22 | var App = require('express')(); 23 | var Jammin = require('jammin'); 24 | var API = new Jammin.API('mongodb://:@'); 25 | 26 | var PetSchema = { 27 | name: String, 28 | age: Number 29 | }; 30 | 31 | API.define('Pet', PetSchema); 32 | API.Pet.get('/pets/:name'); 33 | API.Pet.post('/pets'); 34 | 35 | App.use('/v0', API.router); 36 | App.listen(3000); 37 | ``` 38 | 39 | ```bash 40 | > curl -X POST $HOST/v0/pets -d '{"name": "Lucy", "age": 2}' 41 | {"success": true} 42 | > curl $HOST/v0/pets/Lucy 43 | {"name": "Lucy", "age": 2} 44 | ``` 45 | 46 | ### Modules (beta) 47 | Use ```API.module()``` to automatically pass ```req.query``` and ```req.body``` as arguments to a pre-defined set of functions. 48 | This example exposes filesystem operations to the API client. 49 | ```js 50 | var App = require('express')(); 51 | var Jammin = require('jammin'); 52 | 53 | var API = new Jammin.API(); 54 | API.module('/files', {module: require('fs'), async: true}); 55 | 56 | App.use('/v0', API.router); 57 | App.listen(3000); 58 | ``` 59 | ```bash 60 | > curl -X POST $HOST/v0/files/writeFile?path=hello.txt -d {"data": "Hello World!"} 61 | > curl -X POST $HOST/v0/files/readFile?path=hello.txt 62 | Hello World! 63 | ``` 64 | Use ```Jammin.Client()``` to create a client of the remote module. 65 | ```js 66 | var RemoteFS = new Jammin.Client({ 67 | module: require('fs'), 68 | basePath: '/files', 69 | host: 'http://127.0.0.1:3000', 70 | }); 71 | RemoteFS.writeFile('foo.txt', 'Hello World!', function(err) { 72 | RemoteFS.readFile('foo.txt', function(err, contents) { 73 | console.log(contents); // Hello World! 74 | }); 75 | }); 76 | ``` 77 | 78 | ## Documentation 79 | 80 | ### Database Operations 81 | 82 | **GET** 83 | ```get()``` will use ```req.params``` and ```req.query``` to **find an item** or array of items in the database. 84 | ```js 85 | API.Pet.get('/pet/:name'); 86 | API.Pet.getMany('/pets') 87 | ``` 88 | **POST** 89 | ```post()``` will use ```req.body``` to **create a new item** or set of items in the database. 90 | ```js 91 | API.Pet.post('/pets'); 92 | API.Pet.postMany('/pets'); 93 | ``` 94 | **PATCH** 95 | ```patch()``` will use ```req.params``` and ```req.query``` to find an item or set of items in the database, and use ```req.body``` to **update those items**. 96 | ```js 97 | API.Pet.patch('/pets/:name'); 98 | API.Pet.patchMany('/pets'); 99 | ``` 100 | **PUT** 101 | ```put()``` will use ```req.params``` and ```req.query``` to find an item or set of items in the database, and use ```req.body``` to **update those items, or create a new item** if none exists 102 | ```js 103 | API.Pet.put('/pets/:name'); 104 | API.Pet.putMany('/pets'); 105 | ``` 106 | **DELETE** 107 | ```delete()``` will use ```req.params``` and ```req.query``` to **remove an item** or set of items from the database 108 | ```js 109 | API.Pet.delete('/pets/:name'); 110 | API.Pet.deleteMany('/pets'); 111 | ``` 112 | 113 | ### Schemas and Validation 114 | See the documentation for [Mongoose Schemas](http://mongoosejs.com/docs/guide.html) for the full set of features. 115 | #### Require fields 116 | ```js 117 | var PetSchema = { 118 | name: {type: String, required: true} 119 | } 120 | ``` 121 | #### Hide fields 122 | ```js 123 | var UserSchema = { 124 | username: String, 125 | password_hash: {type: String, select: false} 126 | } 127 | ``` 128 | 129 | ### Modules (beta) 130 | Jammin allows you to expose arbitrary functions as API endpoints. For example, we can give API clients access to the filesystem. 131 | ```js 132 | API.module('/files', {module: require('fs'), async: true}) 133 | ``` 134 | Jammin will expose top-level functions in the module as POST requests. Arguments can be passed in-order as a JSON array in the POST body. Jammin also parses the function's toString() to get parameter names, allowing arguments to be passed via a JSON object in the POST body (using the parameter names as keys). Strings can also be passed in as query parameters. 135 | 136 | All three of the following calls are equivalent: 137 | ```bash 138 | > curl -X POST $HOST/files?path=foo.txt&data=hello 139 | > curl -X POST $HOST/files -d '{"path": "foo.txt", "data": "hello"}' 140 | > curl -X POST $HOST/files -d '["foo.txt", "hello"]' 141 | ``` 142 | See the Middleware section below for an example of how to more safely expose fs 143 | 144 | Jammin also provides clients for exposed modules. This allows you to bridge function calls over HTTP, effectively allowing you to ```require()``` modules from a remote client. 145 | 146 | This allows you to quickly containerize node modules that communicate via JSON-serializable data, e.g. to place a particularly expensive operation behind a load balancer, or to run potentially malicious code inside a sandboxed container. 147 | 148 | ```js 149 | var RemoteFS = new Jammin.Client({ 150 | module: require('fs'), 151 | basePath: '/files', 152 | host: 'http://127.0.0.1:3000', 153 | }); 154 | ``` 155 | 156 | ### Middleware 157 | You can use middleware to intercept database calls, alter the request, perform authentication, etc. 158 | 159 | Change ```req.jammin.query``` to alter how Jammin selects items from the database (GET, PATCH, PUT, DELETE). 160 | 161 | Change ```req.jammin.document``` to alter the document Jammin will insert into the database (POST, PATCH, PUT). 162 | 163 | Change ```req.jammin.method``` to alter how Jammin interacts with the database. 164 | 165 | Change ```req.jammin.arguments``` to alter function calls made to modules. 166 | 167 | The example below alters ```req.query``` to construct a complex Mongo query from user inputs. 168 | ```js 169 | API.Pet.getMany('/search/pets', function(req, res, next) { 170 | req.jammin.query = { 171 | name: { "$regex": new RegExp(req.query.q) } 172 | }; 173 | next(); 174 | }); 175 | ``` 176 | A more complex example achieves lazy deletion: 177 | ```js 178 | API.router.use('/pets', function(req, res, next) { 179 | if (req.method === 'DELETE') { 180 | req.jammin.method = 'PATCH'; 181 | req.jammin.document = {deleted: true}; 182 | } else if (req.method === 'GET') { 183 | req.jammin.query.deleted = {"$ne": true}; 184 | } else if (req.method === 'POST' || req.method === 'PUT') { 185 | req.jammin.document.deleted = false; 186 | } 187 | next(); 188 | } 189 | ``` 190 | Or resource ownership: 191 | ```js 192 | var setOwnership = function(req, res, next) { 193 | req.jammin.document.owner = req.user.username; 194 | next(); 195 | } 196 | var ownersOnly = function(req, res, next) { 197 | req.jammin.query.owner = {"$eq": req.user.username}; 198 | next(); 199 | } 200 | API.Pets.get('/pets'); 201 | API.Pets.post('/pets', setOwnership); 202 | API.Pets.patch('/pets/:id', ownersOnly); 203 | API.Pets.delete('/pets/:id', ownersOnly); 204 | ``` 205 | You can also use middleware to alter calls to module functions. This function sanitizes calls to fs: 206 | ```js 207 | API.module('/files', {module: require('fs'), async: true}, function(req, res, next) { 208 | if (req.path.indexOf('Sync') !== -1) return res.status(400).send("Synchronous functions not allowed"); 209 | // Remove path traversals 210 | req.jammin.arguments[0] = Path.join('/', req.jammin.arguments[0]); 211 | // Make sure all operations are inside __dirname/user_files 212 | req.jammin.arguments[0] = Path.join(__dirname, 'user_files', req.jammin.arguments[0]); 213 | next(); 214 | }); 215 | ``` 216 | 217 | ### Swagger (beta) 218 | Serve a [Swagger specification](http://swagger.io) for your API at the specified path. You can use this to document your API via [Swagger UI](https://github.com/swagger-api/swagger-ui) or a [LucyBot portal](https://lucybot.com) 219 | ```js 220 | API.swagger('/swagger.json'); 221 | ``` 222 | Jammin will automatically fill out most of your spec, but you can provide additional information: 223 | ```js 224 | var API = new Jammin.API({ 225 | databaseURL: DatabaseURL, 226 | swagger: { 227 | info: {title: 'Pet Store'}, 228 | host: 'api.example.com', 229 | basePath: '/api' 230 | } 231 | }); 232 | ``` 233 | 234 | ## Extended Usage 235 | See the example [Petstore Server](test/petstore-server.js) for other examples. 236 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jammin 2 | 3 | ## Installation 4 | ```npm install jammin``` 5 | 6 | ## About 7 | Jammin is a concise, extensible framework for building REST APIs in NodeJS. It consists of: 8 | * A light-weight wrapper around [Mongoose](http://mongoosejs.com/) to perform database operations 9 | * An Express router to link database operations to HTTP operations 10 | 11 | Jammin is built for [Express](http://expressjs.com/) and is fully extensible via **middleware** to support things like authentication, sanitization, and resource ownership. 12 | 13 | Use ```API.addModel()``` to add an existing Mongoose model. You can attach HTTP routes to each model that will use ```req.params``` and ```req.query``` to query the database and ```req.body``` to update it. 14 | 15 | ## Quickstart 16 | 17 | ```js 18 | var App = require('express')(); 19 | var Mongoose = require('mongoose'); 20 | var Jammin = require('jammin'); 21 | var API = new Jammin.API('mongodb://:@'); 22 | 23 | var PetSchema = Mongoose.Schema({ 24 | name: String, 25 | age: Number 26 | }); 27 | 28 | var Pet = Mongoose.model('Pet', PetSchema); 29 | API.addModel('Pet', Pet); 30 | API.Pet.get('/pets/:name'); 31 | API.Pet.post('/pets'); 32 | 33 | App.use('/v0', API.router); 34 | App.listen(3000); 35 | ``` 36 | 37 | ```bash 38 | > curl -X POST $HOST/v0/pets -d '{"name": "Lucy", "age": 2}' 39 | {"success": true} 40 | > curl $HOST/v0/pets/Lucy 41 | {"name": "Lucy", "age": 2} 42 | ``` 43 | 44 | ## Documentation 45 | 46 | ### Database Operations 47 | 48 | **GET** 49 | ```get()``` will use ```req.params``` and ```req.query``` to **find an item** or array of items in the database. 50 | ```js 51 | API.Pet.get('/pets/:name'); 52 | API.Pet.getMany('/pets') 53 | ``` 54 | **POST** 55 | ```post()``` will use ```req.body``` to **create a new item** or set of items in the database. 56 | ```js 57 | API.Pet.post('/pets'); 58 | API.Pet.postMany('/pets'); 59 | ``` 60 | **PATCH** 61 | ```patch()``` will use ```req.params``` and ```req.query``` to find an item or set of items in the database, and use ```req.body``` to **update those items**. 62 | ```js 63 | API.Pet.patch('/pets/:name'); 64 | API.Pet.patchMany('/pets'); 65 | ``` 66 | **PUT** 67 | ```put()``` will use ```req.params``` and ```req.query``` to find an item or set of items in the database, and use ```req.body``` to **update those items, or create a new item** if none exists 68 | ```js 69 | API.Pet.put('/pets/:name'); 70 | API.Pet.putMany('/pets'); 71 | ``` 72 | **DELETE** 73 | ```delete()``` will use ```req.params``` and ```req.query``` to **remove an item** or set of items from the database 74 | ```js 75 | API.Pet.delete('/pets/:name'); 76 | API.Pet.deleteMany('/pets'); 77 | ``` 78 | 79 | ### Schemas and Validation 80 | See the documentation for [Mongoose Schemas](http://mongoosejs.com/docs/guide.html) for the full set of features. 81 | #### Require fields 82 | ```js 83 | var PetSchema = { 84 | name: {type: String, required: true} 85 | } 86 | ``` 87 | #### Hide fields 88 | ```js 89 | var UserSchema = { 90 | username: String, 91 | password_hash: {type: String, select: false} 92 | } 93 | ``` 94 | 95 | ### Middleware 96 | You can use middleware to intercept database calls, alter the request, perform authentication, etc. 97 | 98 | Change ```req.jammin.query``` to alter how Jammin selects items from the database (GET, PATCH, PUT, DELETE). 99 | 100 | Change ```req.jammin.document``` to alter the document Jammin will insert into the database (POST, PATCH, PUT). 101 | 102 | Change ```req.jammin.method``` to alter how Jammin interacts with the database. 103 | 104 | Jammin also comes with prepackaged middleware to support the following Mongoose operations: 105 | 106 | `limit`, `sort`, `skip`, `projection`, `populate`, `select` 107 | 108 | #### Examples 109 | Here are three equivalent ways to sort the results and limit how many are returned. 110 | ```js 111 | var J = require('jammin').middleware 112 | 113 | // The following are all equivalent 114 | API.Pet.getMany('/pets', J.limit(20), J.sort('+name')); 115 | API.Pet.getMany('/pets', J({limit: 20, sort: '+name'})); 116 | API.Pet.getMany('/pets', function(req, res, next) { 117 | req.jammin.limit = 20; 118 | req.jammin.sort = '+name'; 119 | next(); 120 | }) 121 | 122 | ``` 123 | 124 | The example below alters ```req.query``` to construct a complex Mongo query from user inputs. 125 | ```js 126 | API.Pet.getMany('/search/pets', function(req, res, next) { 127 | req.jammin.query = { 128 | name: { "$regex": new RegExp(req.query.q) } 129 | }; 130 | next(); 131 | }); 132 | ``` 133 | A more complex example achieves lazy deletion: 134 | ```js 135 | API.router.use('/pets', function(req, res, next) { 136 | if (req.method === 'DELETE') { 137 | req.jammin.method = 'PATCH'; 138 | req.jammin.document = {deleted: true}; 139 | } else if (req.method === 'GET') { 140 | req.jammin.query.deleted = {"$ne": true}; 141 | } else if (req.method === 'POST' || req.method === 'PUT') { 142 | req.jammin.document.deleted = false; 143 | } 144 | next(); 145 | } 146 | ``` 147 | Or resource ownership: 148 | ```js 149 | var setOwnership = function(req, res, next) { 150 | req.jammin.document.owner = req.user._id; 151 | next(); 152 | } 153 | var ownersOnly = function(req, res, next) { 154 | req.jammin.query.owner = {"$eq": req.user._id}; 155 | next(); 156 | } 157 | API.Pet.get('/pets'); 158 | API.Pet.post('/pets', setOwnership); 159 | API.Pet.patch('/pets/:id', ownersOnly); 160 | API.Pet.delete('/pets/:id', ownersOnly); 161 | ``` 162 | 163 | ### Manual Calls and Intercepting Results 164 | You can manually run a Jammin query and view the results before sending them to the user. Simply call the operation you want without a path. 165 | 166 | Jammin will automatically handle 404 and 500 errors, but will pass the results of successful operations to your callback. 167 | 168 | ```js 169 | app.get('/pets', J.limit(20), function(req, res) { 170 | API.Pet.getMany(req, res, function(pets) { 171 | res.json(pets); 172 | }) 173 | }) 174 | ``` 175 | 176 | If you'd like to handle errors manually, you can also access the underlying model: 177 | 178 | ```js 179 | API.Pet.model.findOneAndUpdate(...); 180 | ``` 181 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Jammin = module.exports = {} 2 | Jammin.API = require('./lib/api.js'); 3 | Jammin.Client = require('./lib/client.js'); 4 | Jammin.middleware = require('./lib/middleware.js'); 5 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | var Mongoose = require('mongoose'); 2 | var Express = require('express'); 3 | var BodyParser = require('body-parser'); 4 | 5 | var ModuleBuilder = require('./module-builder.js'); 6 | var Definition = require('./definition.js'); 7 | var SwaggerBuilder = require('./swagger-builder.js'); 8 | 9 | var METHODS = ['get', 'patch', 'put', 'post', 'delete']; 10 | 11 | var setJamminDefaults = function(req, res, next) { 12 | if (!req.jammin) { 13 | req.jammin = { 14 | query: JSON.parse(JSON.stringify(req.query)), 15 | document: JSON.parse(JSON.stringify(req.body)), 16 | arguments: JSON.parse(JSON.stringify(req.body)), 17 | method: req.method.toLowerCase(), 18 | } 19 | } 20 | for (key in req.params) { 21 | req.jammin.query[key] = req.params[key]; 22 | } 23 | next(); 24 | } 25 | 26 | /** API **/ 27 | 28 | var API = module.exports = function(options) { 29 | options = options || {}; 30 | if (typeof options === 'string') options = {databaseURL: options}; 31 | var self = this; 32 | self.o = options; 33 | self.router = Express.Router(); 34 | self.router.use(BodyParser.json()); 35 | self.moduleBuilder = new ModuleBuilder(self.router); 36 | METHODS.concat(['use']).forEach(function(method) { 37 | var origFunc = self.router[method]; 38 | self.router[method] = function() { 39 | arguments = Array.prototype.slice.call(arguments); 40 | var insertLoc = 0; 41 | for (var i = 0; i < arguments.length; ++i) { 42 | if (typeof arguments[i] === 'function') { 43 | insertLoc = i; 44 | break; 45 | } 46 | } 47 | arguments.splice(insertLoc, 0, setJamminDefaults); 48 | return origFunc.apply(self.router, arguments); 49 | } 50 | }) 51 | if (options.databaseURL) { 52 | self.mongoose = Mongoose.createConnection(options.databaseURL); 53 | } else { 54 | self.mongoose = options.connection; 55 | } 56 | self.swaggerBuilder = new SwaggerBuilder(options.swagger); 57 | } 58 | 59 | API.prototype.module = function() { 60 | this.moduleBuilder.addRoutes.apply(this.moduleBuilder, arguments); 61 | } 62 | 63 | API.Schema = Mongoose.Schema; 64 | 65 | API.prototype.define = function(label, schema) { 66 | this.swaggerBuilder.addDefinition(label, schema); 67 | if (Object.keys(this).indexOf(label) !== -1) { 68 | throw new Error("Invalid label " + label + "\nLabel is restricted or already defined") 69 | } 70 | this[label] = new Definition(this, label, Mongoose.model(label, schema)); 71 | } 72 | 73 | API.prototype.addModel = function(label, model) { 74 | this[label] = new Definition(this, label, model); 75 | } 76 | 77 | API.prototype.swagger = function() { 78 | var self = this; 79 | arguments = Array.prototype.slice.call(arguments); 80 | var path = arguments[0]; 81 | var middleware = arguments.splice(1); 82 | var options = typeof middleware[0] === 'object' ? middleware.shift() : {}; 83 | middleware.unshift(path); 84 | middleware.push(function(req, res, next) { 85 | if (req.query.pretty) { 86 | res.set('Content-Type', 'application/json'); 87 | res.send(JSON.stringify(self.swaggerBuilder.swagger, null, 2)); 88 | } else { 89 | res.json(self.swaggerBuilder.swagger); 90 | } 91 | }) 92 | self.router.get.apply(self.router, middleware); 93 | } 94 | 95 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | var Request = require('request'); 2 | var Path = require('path'); 3 | 4 | var Client = module.exports = function(options) { 5 | this.options = options; 6 | this.addFunctionsFromObject(options.module, options.basePath || '/'); 7 | } 8 | 9 | Client.prototype.addFunctionsFromObject = function(obj, path, keys) { 10 | var self = this; 11 | keys = keys || []; 12 | for (var key in obj) { 13 | var fn = obj[key]; 14 | var newPath = Path.join(path, key); 15 | if (typeof fn === 'function' || fn === true) { 16 | var url = self.options.host + newPath; 17 | var container = this; 18 | keys.forEach(function(k) { 19 | container[k] = container[k] || {}; 20 | container = container[k]; 21 | }); 22 | container[key] = getProxyFunction(url); 23 | } else if (typeof fn === 'object' && !Array.isArray(fn)) { 24 | self.addFunctionsFromObject(fn, newPath, keys.concat(key)); 25 | } 26 | } 27 | } 28 | 29 | var getProxyFunction = function(url) { 30 | return function() { 31 | arguments = Array.prototype.slice.call(arguments); 32 | var callback = arguments.pop(); 33 | Request.post({ 34 | url: url, 35 | json: true, 36 | body: arguments, 37 | }, function(err, resp, body) { 38 | if (err) callback(err); 39 | else if (resp.statusCode === 500) callback(body.error); 40 | else if (resp.statusCode !== 200) callback({statusCode: resp.statusCode}); 41 | else callback(null, body); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/definition.js: -------------------------------------------------------------------------------- 1 | var Async = require('async'); 2 | var Mongoose = require('mongoose'); 3 | var BodyParser = require('body-parser'); 4 | var PathToRegexp = require('path-to-regexp'); 5 | 6 | var Definition = module.exports = function(api, label, model) { 7 | this.api = api; 8 | this.label = label; 9 | this.db = this.model = model; 10 | } 11 | 12 | var METHODS = ['get', 'patch', 'put', 'post', 'delete']; 13 | var TO_OBJ_OPTIONS = { 14 | versionKey: false, 15 | setters: true, 16 | getters: true, 17 | virtuals: false, 18 | } 19 | 20 | Definition.prototype.queryDB = function(method, many, jammin, options, callback) { 21 | var self = this; 22 | var query = jammin.query; 23 | var doc = jammin.document; 24 | if (method === 'get') { 25 | var find = many ? self.db.find : self.db.findOne; 26 | var run = find.apply(self.db, [query, jammin.projection]).select(options.select || '').populate(options.populate || ''); 27 | if (jammin.populate) { 28 | if (Array.isArray(jammin.populate)) run.populate(jammin.populate[0], jammin.populate[1]); 29 | else run.populate(jammin.populate); 30 | } 31 | if (jammin.select) run.select(jammin.select); 32 | if (jammin.sort) run.sort(jammin.sort); 33 | if (jammin.limit) run.limit(jammin.limit); 34 | if (jammin.skip) run.skip(jammin.skip); 35 | run.exec(callback); 36 | } else if (method === 'post') { 37 | var docs = many ? doc : [doc]; 38 | Async.parallel(docs.map(function(doc) { 39 | return function(callback) { 40 | var toAdd = new self.db(doc); 41 | toAdd.save(function(err, newDoc) { 42 | callback(err, newDoc); 43 | }); 44 | } 45 | }), function(err, results) { 46 | callback(err, many ? results : results[0]); 47 | }) 48 | } else if (method === 'put') { 49 | var docs = many ? doc : [doc]; 50 | Async.parallel(docs.map(function(doc) { 51 | return function(callback) { 52 | self.db.findOneAndUpdate(query, doc, {upsert: true, new: true, runValidators: true}).exec(callback); 53 | } 54 | }), function(err, docs) { 55 | if (!err && !many) docs = docs[0]; 56 | callback(err, docs) 57 | }); 58 | } else if (method === 'patch') { 59 | if (many) { 60 | // TODO: setters are not called here. 61 | self.db.update(query, doc, {multi: true, runValidators: true}).exec(function(err) { 62 | callback(err, {success: true}); 63 | }) 64 | } else { 65 | self.db.findOne(query).exec(function(err, oldDoc) { 66 | if (err || !oldDoc) return callback(err, oldDoc); 67 | for (var key in doc) { 68 | oldDoc[key] = doc[key]; 69 | oldDoc.markModified(key); 70 | } 71 | oldDoc.save(callback); 72 | }) 73 | } 74 | } else if (method === 'delete') { 75 | var remove = many ? self.db.remove : self.db.findOneAndRemove; 76 | remove.apply(self.db, [query]).exec(function(err, thing) { 77 | callback(err, thing); 78 | }) 79 | } else { 80 | throw new Error("Unsupported method:" + method); 81 | } 82 | } 83 | 84 | var getRouteFunction = function(method, many) { 85 | // Can be called with (path[, middleware...]) or (req, res, callback); 86 | return function() { 87 | var self = this; 88 | arguments = Array.prototype.slice.call(arguments); 89 | var isMiddleware = typeof arguments[0] !== 'string'; 90 | var sendResponse = !isMiddleware || arguments.length === 2; 91 | var options = sendResponse && typeof arguments[1] === 'object' ? arguments[1] : {}; 92 | var dbAction = function(req, res, next) { 93 | var useMethod = isMiddleware ? method : req.jammin.method; 94 | self.queryDB(useMethod, many, req.jammin, options, function(err, thing) { 95 | if (err) res.status(500).json({error: err.toString()}); 96 | else if (!thing) res.status(404).json({error: 'Not Found'}) 97 | else if (sendResponse && (useMethod === 'get' || useMethod === 'post' || useMethod === 'put')) { 98 | if (many) thing = thing.map(function(t) { return t.toJSON(TO_OBJ_OPTIONS) }); 99 | else thing = thing.toJSON(TO_OBJ_OPTIONS); 100 | if (options.mapItem) { 101 | if (many) thing = thing.map(options.mapItem); 102 | else thing = options.mapItem(thing); 103 | } 104 | res.json(thing); 105 | } 106 | else if (sendResponse) return res.json({success: true}) 107 | else next(thing); 108 | }) 109 | } 110 | // If we get (req, res, callback) instead of a path, just apply 111 | // dbAction. Otherwise delegate to the router. 112 | if (isMiddleware) return dbAction.apply(self, arguments); 113 | 114 | arguments = Array.prototype.slice.call(arguments); 115 | var expressPath = arguments[0]; 116 | var keys = []; 117 | PathToRegexp(expressPath, keys); 118 | var swaggerPath = expressPath; 119 | keys.forEach(function(key) { 120 | swaggerPath = swaggerPath.replace(':' + key.name, '{' + key.name + '}'); 121 | }); 122 | var middleware = arguments.splice(1); 123 | if (typeof middleware[0] === 'object') middleware.shift(); 124 | if (options.swagger) options.swagger = JSON.parse(JSON.stringify(options.swagger)); 125 | self.api.swaggerBuilder.addRoute({ 126 | method: method, 127 | path: swaggerPath, 128 | collection: self.label, 129 | many: many 130 | }, options.swagger); 131 | middleware.push(dbAction); 132 | middleware.unshift(expressPath); 133 | this.api.router[method].apply(this.api.router, middleware); 134 | } 135 | } 136 | 137 | METHODS.forEach(function(method) { 138 | Definition.prototype[method] = getRouteFunction(method); 139 | Definition.prototype[method + 'Many'] = getRouteFunction(method, true) 140 | }) 141 | -------------------------------------------------------------------------------- /lib/middleware.js: -------------------------------------------------------------------------------- 1 | var SETTERS = ['limit', 'sort', 'skip', 'projection', 'populate', 'select']; 2 | 3 | var J = module.exports = function(args) { 4 | for (var key in args) { 5 | if (SETTERS.indexOf(key) === -1) throw new Error("Unsupported key for Jammin: " + key) 6 | } 7 | return function(req, res, next) { 8 | for (var key in args) { 9 | req.jammin[key] = args[key]; 10 | } 11 | next(); 12 | } 13 | }; 14 | 15 | SETTERS.forEach(function(s) { 16 | J[s] = function(arg) { 17 | return function(req, res, next) { 18 | req.jammin[s] = arg; 19 | next(); 20 | } 21 | } 22 | }) 23 | 24 | -------------------------------------------------------------------------------- /lib/module-builder.js: -------------------------------------------------------------------------------- 1 | var Path = require('path'); 2 | var GetParameterNames = require('get-parameter-names'); 3 | 4 | var ModuleBuilder = module.exports = function(router) { 5 | this.router = router; 6 | } 7 | 8 | ModuleBuilder.prototype.addRoutes = function() { 9 | arguments = Array.prototype.slice.call(arguments); 10 | var basePath = arguments.shift(); 11 | var options = arguments.shift(); 12 | var module = options.module; 13 | var middleware = arguments; 14 | this.addRoutesFromObject(module, basePath, middleware, options); 15 | } 16 | 17 | ModuleBuilder.prototype.addRoutesFromObject = function(obj, path, middleware, options) { 18 | var self = this; 19 | for (var key in obj) { 20 | var item = obj[key]; 21 | var newPath = Path.join(path, key); 22 | if (typeof item === 'function') { 23 | var postArgs = [ 24 | newPath, 25 | getArgMiddleware(item, options), 26 | ].concat(middleware).concat([ 27 | getMiddleware(obj, key, options), 28 | ]); 29 | self.router.post.apply(self.router, postArgs); 30 | } else if (typeof item === 'object' && !Array.isArray(item)) { 31 | self.addRoutesFromObject(item, newPath, middleware, options); 32 | } 33 | } 34 | } 35 | 36 | var getArgMiddleware = function(fn, options) { 37 | var args = GetParameterNames(fn); 38 | return function(req, res, next) { 39 | var convertArgs = typeof req.jammin.arguments === 'object' && !Array.isArray(req.jammin.arguments); 40 | var bodyArgs = convertArgs ? req.jammin.arguments : {}; 41 | if (convertArgs) req.jammin.arguments = []; 42 | args.forEach(function(arg, index) { 43 | if (typeof req.query[arg] !== 'undefined') { 44 | req.jammin.arguments[index] = req.query[arg]; 45 | } 46 | if (typeof bodyArgs[arg] !== 'undefined') { 47 | req.jammin.arguments[index] = bodyArgs[arg]; 48 | } 49 | }); 50 | next(); 51 | } 52 | } 53 | 54 | var getMiddleware = function(obj, fnName, options) { 55 | var fn = obj[fnName]; 56 | return function(req, res, next) { 57 | if (!options.async) { 58 | try { 59 | var result = fn.apply(fn, req.jammin.arguments); 60 | res.json(result); 61 | } catch (err) { 62 | res.status(500).json({error: err.toString()}); 63 | throw err; 64 | } 65 | } else { 66 | req.jammin.arguments.push(function(err, result) { 67 | if (err) { 68 | if (err instanceof Error) return res.status(500).json({error: err.toString()}) 69 | else return res.status(500).json({error: err}); 70 | } else { 71 | res.json(result); 72 | } 73 | }); 74 | try { 75 | fn.apply(obj, req.jammin.arguments); 76 | } catch (err) { 77 | res.status(500).json({error: err.toString()}); 78 | throw err; 79 | } 80 | } 81 | } 82 | } 83 | 84 | -------------------------------------------------------------------------------- /lib/swagger-builder.js: -------------------------------------------------------------------------------- 1 | var Mongoose = require('mongoose'); 2 | 3 | var Builder = function(swagger) { 4 | this.schemas = {}; 5 | this.swagger = swagger || {}; 6 | this.swagger.swagger = '2.0'; 7 | this.swagger.paths = {}; 8 | this.swagger.definitions = {}; 9 | this.swagger.info = this.swagger.info || {}; 10 | this.swagger.info.title = this.swagger.info.title || 'My API'; 11 | this.swagger.info.version = this.swagger.info.version || '0.0'; 12 | this.swagger.host = this.swagger.host || '127.0.0.1'; 13 | this.swagger.basePath = this.swagger.basePath || '/'; 14 | this.swagger.produces = this.swagger.produces || ['application/json']; 15 | this.swagger.consumes = this.swagger.consumes || ['application/json']; 16 | }; 17 | module.exports = Builder; 18 | 19 | Builder.prototype.addDefinition = function(label, schema) { 20 | this.schemas[label] = schema; 21 | this.swagger.definitions[label] = convertSchema(schema); 22 | } 23 | 24 | Builder.prototype.addRoute = function(details, override) { 25 | var path = this.swagger.paths[details.path] = this.swagger.paths[details.path] || {}; 26 | if (override) override = JSON.parse(JSON.stringify(override)); 27 | var route = path[details.method] = override || {}; 28 | route.description = route.description || getRouteDescription(details); 29 | if (!route.parameters) { 30 | route.parameters = []; 31 | var schema = this.swagger.definitions[details.collection] || {properties: {}}; 32 | var namedParams = route.parameters.map(function(param) {return param.name}) 33 | var pathParams = (details.path.match(/{\w+}/) || []).map(function(param) { 34 | return param.substring(1, param.length - 1); 35 | }) 36 | pathParams.forEach(function(name) { 37 | if (namedParams.indexOf(name) !== -1) return; 38 | var prop = schema.properties[name]; 39 | namedParams.push(name); 40 | var parameter = { 41 | name: name, 42 | in: 'path', 43 | type: prop ? prop.type : 'string' 44 | }; 45 | parameter.description = getParameterDescription(details, parameter); 46 | route.parameters.push(parameter) 47 | }); 48 | if (details.method !== 'post') { 49 | for (name in schema.properties) { 50 | if (namedParams.indexOf(name) !== -1) continue; 51 | namedParams.push(name); 52 | var parameter = { 53 | name: name, 54 | in: 'query', 55 | type: schema.properties[name].type 56 | } 57 | parameter.description = getParameterDescription(details, parameter); 58 | route.parameters.push(parameter); 59 | } 60 | } 61 | if (details.method !== 'get' && details.method !== 'delete') { 62 | var ref = {'$ref': '#/definitions/' + details.collection}; 63 | var parameter = { 64 | name: 'body', 65 | in: 'body', 66 | schema: !details.many ? ref : {type: 'array', items: ref} 67 | }; 68 | parameter.description = getParameterDescription(details, parameter); 69 | route.parameters.push(parameter) 70 | } 71 | } 72 | route.responses = route.responses || {}; 73 | route.responses['200'] = route.responses['200'] || { 74 | description: getResponseDescription(details), 75 | schema: getResponseSchema(details), 76 | }; 77 | } 78 | 79 | var getRouteDescription = function(opts) { 80 | var desc = ''; 81 | 82 | if (opts.method === 'get') desc += 'Retrieves '; 83 | else if (opts.method === 'post') desc += 'Adds '; 84 | else if (opts.method === 'patch') desc += 'Updates '; 85 | else if (opts.method === 'delete') desc += 'Removes '; 86 | var collDesc = getCollectionDescription(opts); 87 | desc += collDesc.charAt(0).toLowerCase() + collDesc.substring(1); 88 | 89 | return desc; 90 | } 91 | 92 | var getParameterDescription = function(opts, parameter) { 93 | if (parameter.in === 'path' || parameter.in === 'query') { 94 | if (opts.many) { 95 | return 'Filter by ' + parameter.name; 96 | } else { 97 | return 'Select by ' + parameter.name; 98 | } 99 | } else if (parameter.in === 'body') { 100 | if (opts.method === 'post') { 101 | return getCollectionDescription(opts) + ' to create.'; 102 | } else if (opts.method === 'put') { 103 | return getCollectionDescription(opts) + ' to update or create.' 104 | } else if (opts.method === 'patch') { 105 | return opts.collection + ' fields to update.' 106 | } 107 | } 108 | } 109 | 110 | var getResponseDescription = function(opts) { 111 | if (opts.method === 'get' || (opts.method === 'patch' && !opts.many)) { 112 | return getCollectionDescription(opts); 113 | } else { 114 | return 'Success'; 115 | } 116 | } 117 | 118 | var getResponseSchema = function(opts) { 119 | if (opts.method === 'get' || (opts.method === 'patch' && !opts.many)) { 120 | var ref = {'$ref': '#/definitions/' + opts.collection} 121 | return !opts.many ? ref : {type: 'array', items: ref}; 122 | } 123 | } 124 | 125 | var getCollectionDescription = function(opts) { 126 | if (opts.many) return 'An array of ' + opts.collection + 's'; 127 | else return 'A ' + opts.collection; 128 | } 129 | 130 | var convertSchema = function(mongoose) { 131 | var json = {type: 'object', properties: {}}; 132 | for (key in mongoose) { 133 | json.properties[key] = convertSchemaItem(mongoose[key]); 134 | } 135 | return json; 136 | } 137 | 138 | var convertSchemaItem = function(mongoose) { 139 | if (!mongoose) { 140 | return {}; 141 | } 142 | if (Array.isArray(mongoose)) { 143 | if (mongoose.length > 0) { 144 | return {type: 'array', items: convertSchemaItem(mongoose[0])}; 145 | } else { 146 | return {type: 'array'} 147 | } 148 | } 149 | if (typeof mongoose !== 'object') { 150 | mongoose = {type: mongoose}; 151 | } else if (!mongoose.type) { 152 | return convertSchema(mongoose); 153 | } 154 | 155 | var json = {}; 156 | var type = mongoose.type; 157 | if (type === String) { 158 | json.type = 'string'; 159 | } else if (type === Number) { 160 | json.type = 'number'; 161 | } else if (type === Date) { 162 | json.type = 'string'; 163 | json.format = 'date'; 164 | } else if (type === Mongoose.Schema.Types.Mixed) { 165 | json.type = 'object' 166 | } else if (type === Mongoose.Schema.Types.Buffer) { 167 | json.type = 'array'; 168 | json.items = {type: 'string', format: 'byte'}; 169 | } else if (type === Boolean) { 170 | json.type = 'boolean' 171 | } else if (type === Mongoose.Schema.Types.ObjectId) { 172 | json.type = 'string'; 173 | } else if (type === Array) { 174 | return {type: 'array', items: convertSchemaItem(Mongoose.Schema.Types.Mixed)} 175 | } 176 | return json; 177 | } 178 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jammin", 3 | "version": "1.0.0", 4 | "description": "REST API Generator using Express and Mongoose", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/lucybot/jammin.git" 9 | }, 10 | "dependencies": { 11 | "async": "^0.9.0", 12 | "body-parser": "^1.12.3", 13 | "express": "^4.12.3", 14 | "get-parameter-names": "^0.2.0", 15 | "lodash": "^3.10.1", 16 | "mongoose": "^4.2.0", 17 | "path-to-regexp": "^1.0.3", 18 | "request": "^2.55.0" 19 | }, 20 | "devDependencies": { 21 | "chai": "^2.2.0", 22 | "cors": "^2.7.1", 23 | "mockgoose": "^5.0.10", 24 | "password-hash": "^1.2.2", 25 | "validator": "^3.39.0" 26 | }, 27 | "scripts": { 28 | "test": "mocha test/petstore.js && mocha test/modules.js" 29 | }, 30 | "author": "Bobby Brennan (https://lucybot.com/)", 31 | "license": "MIT" 32 | } 33 | -------------------------------------------------------------------------------- /test/middleware.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | var J = require('../lib/middleware.js'); 4 | 5 | describe('Middleware', function() { 6 | it('should allow object API', function(done) { 7 | var req = {jammin: {}}; 8 | J({limit: 20})(req, {}, function() { 9 | expect(req.jammin.limit).to.equal(20); 10 | done(); 11 | }) 12 | }) 13 | 14 | it('should allow function API', function(done) { 15 | var req = {jammin: {}}; 16 | J.limit(20)(req, {}, function() { 17 | expect(req.jammin.limit).to.equal(20); 18 | done(); 19 | }) 20 | }) 21 | 22 | it('should throw error for bad key', function() { 23 | var fn = function() {J({foo: 'bar'})} 24 | expect(fn).to.throw(Error); 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/modules-files/hello.txt: -------------------------------------------------------------------------------- 1 | Hello world -------------------------------------------------------------------------------- /test/modules-files/jailbreak.txt: -------------------------------------------------------------------------------- 1 | JAILBREAK -------------------------------------------------------------------------------- /test/modules-files/via_api.txt: -------------------------------------------------------------------------------- 1 | This file was created via an HTTP call -------------------------------------------------------------------------------- /test/modules-server.js: -------------------------------------------------------------------------------- 1 | var Path = require('path'); 2 | 3 | var Jammin = require('../index.js'); 4 | var API = new Jammin.API(); 5 | 6 | API.module('/validator', {module: require('validator')}); 7 | API.module('/files', { 8 | async: true, 9 | module: require('fs'), 10 | }, function(req, res, next) { 11 | req.jammin.arguments[0] = Path.join('/', req.jammin.arguments[0]); 12 | req.jammin.arguments[0] = Path.join(__dirname, 'modules-files', req.jammin.arguments[0]); 13 | next(); 14 | }) 15 | 16 | var NestedModule = { 17 | foo: function() {return 'foo'}, 18 | nest: { 19 | bar: function() {return 'bar'}, 20 | baz: function() {return 'baz'} 21 | } 22 | } 23 | API.module('/nest', {module: NestedModule}); 24 | 25 | var ErrorModule = { 26 | throwError: function() { 27 | throw new Error("thrown"); 28 | }, 29 | callbackError: function(callback) { 30 | callback({message: "callback"}); 31 | } 32 | } 33 | API.module('/error', {module: ErrorModule, async: true}) 34 | 35 | var App = require('express')(); 36 | App.use(API.router); 37 | 38 | App.use(function(err, req, res, next) { 39 | if (err) console.log('Error found'); 40 | next(); 41 | }) 42 | 43 | var Server = null; 44 | module.exports.listen = function(port) { 45 | console.log('listening: ' + port); 46 | Server = App.listen(port || 3000); 47 | } 48 | module.exports.close = function() { 49 | Server.close(); 50 | } 51 | -------------------------------------------------------------------------------- /test/modules.js: -------------------------------------------------------------------------------- 1 | var Async = require('async'); 2 | var Request = require('request'); 3 | 4 | var Jammin = require('../index.js'); 5 | var Validator = new Jammin.Client({ 6 | module: require('validator'), 7 | basePath: '/validator', 8 | host: 'http://127.0.0.1:3333', 9 | }) 10 | var RemoteFS = new Jammin.Client({ 11 | basePath: '/files', 12 | host: 'http://127.0.0.1:3333', 13 | module: require('fs') 14 | }); 15 | var NestedModule = new Jammin.Client({ 16 | basePath: '/nest', 17 | host: 'http://127.0.0.1:3333', 18 | module: {foo: true, nest: {bar: true, baz: true}} 19 | }); 20 | var ErrorModule = new Jammin.Client({ 21 | basePath: '/error', 22 | host: 'http://127.0.0.1:3333', 23 | module: {throwError: true, callbackError: true} 24 | }); 25 | 26 | var Server = require('./modules-server.js'); 27 | 28 | var expect = require('chai').expect; 29 | 30 | var FILES = [{ 31 | in_filename: 'hello.txt', 32 | out_filename: 'hello.txt', 33 | contents: 'Hello world' 34 | }, { 35 | in_filename: '../../jailbreak.txt', 36 | out_filename: 'jailbreak.txt', 37 | contents: 'JAILBREAK', 38 | }, { 39 | in_filename: 'via_api.txt', 40 | out_filename: 'via_api.txt', 41 | contents: 'This file was created via an HTTP call' 42 | }] 43 | 44 | describe('Validator', function() { 45 | before(function() { 46 | Server.listen(3333); 47 | }) 48 | after(function() { 49 | Server.close(); 50 | }) 51 | 52 | it('should validate email', function(done) { 53 | Async.parallel([ 54 | function(callback) { 55 | Validator.isEmail('foo@bar.com', function(err, isEmail) { 56 | expect(err).to.equal(null); 57 | expect(isEmail).to.equal(true); 58 | callback(); 59 | }); 60 | }, 61 | function(callback) { 62 | Validator.isEmail('foobar.com', function(err, isEmail) { 63 | expect(err).to.equal(null); 64 | expect(isEmail).to.equal(false); 65 | callback(); 66 | }); 67 | } 68 | ], done); 69 | }); 70 | }); 71 | 72 | describe('RemoteFS', function() { 73 | before(function() { 74 | Server.listen(3333); 75 | }); 76 | 77 | it('should allow creating files', function(done) { 78 | RemoteFS.writeFile(FILES[0].in_filename, FILES[0].contents, function(err) { 79 | expect(err).to.equal(null); 80 | done(); 81 | }); 82 | }); 83 | 84 | it('should not create files outside of directory', function(done) { 85 | RemoteFS.writeFile(FILES[1].in_filename, FILES[1].contents, function(err) { 86 | expect(err).to.equal(null); 87 | done(); 88 | }) 89 | }); 90 | 91 | it('should allow creating files by API', function(done) { 92 | Request({ 93 | url: 'http://127.0.0.1:3333/files/writeFile?path=' + FILES[2].in_filename, 94 | method: 'post', 95 | body: { 96 | data: FILES[2].contents 97 | }, 98 | json: true, 99 | }, function(err, resp, body) { 100 | expect(err).to.equal(null); 101 | done(); 102 | }) 103 | }); 104 | 105 | it('should allow getting files', function(done) { 106 | Async.parallel(FILES.map(function(file) { 107 | return function(callback) { 108 | RemoteFS.readFile(file.out_filename, 'utf8', function(err, contents) { 109 | expect(err).to.equal(null); 110 | expect(contents).to.equal(file.contents); 111 | callback(); 112 | }); 113 | } 114 | }), function(err) { 115 | done(); 116 | }) 117 | }); 118 | }); 119 | 120 | describe('nested modules', function() { 121 | it('should have top level function', function(done) { 122 | NestedModule.foo(function(err, foo) { 123 | expect(err).to.equal(null); 124 | expect(foo).to.equal('foo'); 125 | done(); 126 | }); 127 | }); 128 | 129 | it('should have nested functions', function(done) { 130 | NestedModule.nest.bar(function(err, bar) { 131 | expect(err).to.equal(null); 132 | expect(bar).to.equal('bar'); 133 | done(); 134 | }); 135 | }); 136 | }); 137 | 138 | describe('errors', function() { 139 | it('should be able to be thrown', function(done) { 140 | ErrorModule.throwError(function(err) { 141 | expect(err).to.equal("Error: thrown"); 142 | done(); 143 | }); 144 | }); 145 | 146 | it('should be able to be called back', function(done) { 147 | ErrorModule.callbackError(function(err) { 148 | expect(err).to.deep.equal({message: "callback"}) 149 | done(); 150 | }) 151 | }) 152 | }) 153 | -------------------------------------------------------------------------------- /test/petstore-server.js: -------------------------------------------------------------------------------- 1 | var FS = require('fs'); 2 | var Hash = require('password-hash'); 3 | var Mongoose = require('mongoose'); 4 | require('mockgoose')(Mongoose); 5 | var App = require('express')(); 6 | App.use(require('cors')()); 7 | 8 | module.exports.listen = function(port, done) { 9 | console.log('listening: ' + port); 10 | App.listen(port || 3000); 11 | Mongoose.connect('mongodb://example.com/TestingDB', done); 12 | } 13 | 14 | var Jammin = require('../index.js'), 15 | J = Jammin.middleware; 16 | var API = new Jammin.API({ 17 | connection: Mongoose, 18 | }); 19 | 20 | var UserSchema = Mongoose.Schema({ 21 | username: {type: String, required: true, unique: true, match: /^\w+$/}, 22 | password_hash: {type: String, required: true, select: false}, 23 | }) 24 | 25 | var vaccSchema = Mongoose.Schema({ 26 | name: String, 27 | date: Date, 28 | }, {_id: false}) 29 | 30 | var PetSchema = Mongoose.Schema({ 31 | id: {type: Number, required: true, unique: true}, 32 | name: String, 33 | owner: String, 34 | animalType: {type: String, default: 'unknown'}, 35 | vaccinations: [vaccSchema] 36 | }) 37 | 38 | API.addModel('Pet', Mongoose.model('Pet', PetSchema)); 39 | API.addModel('User', Mongoose.model('User', UserSchema)); 40 | 41 | var authenticateUser = function(req, res, next) { 42 | var query = { 43 | username: req.headers['username'], 44 | }; 45 | API.User.model.findOne(query).select('+password_hash').exec(function(err, user) { 46 | if (err) { 47 | res.status(500).json({error: err.toString()}) 48 | } else if (!user || !user.password_hash) { 49 | res.status(401).json({error: "Unknown user:" + query.username}); 50 | } else if (!Hash.verify(req.headers['password'], user.password_hash)) { 51 | res.status(401).json({error: "Invalid password for " + query.username}) 52 | } else { 53 | req.user = user; 54 | next(); 55 | } 56 | }) 57 | } 58 | 59 | // Creates a new user. 60 | API.User.post('/users', function(req, res, next) { 61 | req.jammin.document.password_hash = Hash.generate(req.body.password); 62 | next(); 63 | }); 64 | 65 | // Gets all users 66 | API.User.getMany('/users'); 67 | 68 | // Gets a pet by id. 69 | API.Pet.get('/pets/:id'); 70 | 71 | // Gets an array of pets that match the query. 72 | API.Pet.getMany('/pets'); 73 | 74 | // Searches pets by name 75 | API.Pet.getMany('/search/pets', J.sort('+name'), function(req, res, next) { 76 | req.jammin.query = { 77 | name: { "$regex": new RegExp(req.query.q) } 78 | }; 79 | next(); 80 | }); 81 | 82 | var upsert = function(req, res, next) { 83 | req.jammin.method = 'put'; 84 | next(); 85 | } 86 | 87 | API.Pet.post('/pets/:id', upsert, authenticateUser, function(req, res, next) { 88 | req.jammin.document.owner = req.user.username; 89 | req.jammin.document.id = req.params.id; 90 | next(); 91 | }) 92 | 93 | // Creates one or more new pets. 94 | API.Pet.postMany('/pets', authenticateUser, function(req, res, next) { 95 | if (!Array.isArray(req.jammin.document)) req.jammin.document = [req.jammin.document]; 96 | req.jammin.document.forEach(function(pet) { 97 | pet.owner = req.user.username; 98 | }); 99 | next(); 100 | }); 101 | 102 | // Setting req.jammin.query.owner ensures that only the logged-in user's 103 | // pets will be returned by any queries Jammin makes to the DB. 104 | var enforceOwnership = function(req, res, next) { 105 | req.jammin.query.owner = req.user.username; 106 | next(); 107 | } 108 | 109 | // Changes a pet. 110 | API.Pet.patch('/pets/:id', authenticateUser, enforceOwnership); 111 | 112 | // Changes every pet that matches the query. 113 | API.Pet.patchMany('/pets', authenticateUser, enforceOwnership); 114 | 115 | // Deletes a pet by ID. 116 | API.Pet.delete('/pets/:id', authenticateUser, enforceOwnership); 117 | 118 | // Deletes every pet that matches the query. 119 | API.Pet.deleteMany('/pets', authenticateUser, enforceOwnership); 120 | 121 | API.router.get('/pet_count', function(req, res) { 122 | API.Pet.getMany(req, res, function(pets) { 123 | res.json({count: pets.length}) 124 | }) 125 | }) 126 | 127 | API.Pet.getMany('/pet_types', {mapItem: function(pet) {return pet.animalType}}) 128 | 129 | App.use('/api', API.router); 130 | -------------------------------------------------------------------------------- /test/petstore.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var FS = require('fs'); 3 | var Request = require('request'); 4 | var Expect = require('chai').expect; 5 | var Petstore = require('./petstore-server.js'); 6 | 7 | var BASE_URL = 'http://127.0.0.1:3333/api'; 8 | 9 | var USER_1 = {username: 'user1', password: 'jabberwocky'} 10 | var USER_2 = {username: 'user2', password: 'cabbagesandkings'} 11 | 12 | var PETS = [ 13 | {id: 0, name: "Pet0", owner: USER_1.username, animalType: "dog"}, 14 | {id: 1, name: "Pet1", owner: USER_1.username, animalType: "gerbil"}, 15 | {id: 2, name: "Pet2", owner: USER_2.username, animalType: "dog"}, 16 | {id: 3, name: "Pet3", owner: USER_1.username, animalType: "cat"}, 17 | {id: 4, name: "Pet4", owner: USER_1.username, animalType: "cat"}, 18 | {id: 5, name: "Pet5", owner: USER_1.username, animalType: "unknown"} 19 | ]; 20 | PETS.forEach(function(pet) { 21 | pet.vaccinations = []; 22 | }); 23 | 24 | var VACCINATIONS = [{name: 'Rabies', date: '1987-09-23T00:00:00.000Z'}]; 25 | 26 | var CurrentPets = []; 27 | 28 | var successResponse = function(expectedBody, done) { 29 | if (!done) { 30 | done = expectedBody; 31 | expectedBody = {success: true}; 32 | } 33 | return function(err, res, body) { 34 | if (body.error) console.log(body.error); 35 | Expect(err).to.equal(null); 36 | Expect(res.statusCode).to.equal(200); 37 | Expect(body.error).to.equal(undefined); 38 | if (!expectedBody) { 39 | Expect(body.success).to.equal(true); 40 | } else { 41 | if (Array.isArray(expectedBody)) { 42 | Expect(body).to.deep.have.members(expectedBody); 43 | Expect(expectedBody).to.deep.have.members(body); 44 | } if (typeof expectedBody === 'function') { 45 | expectedBody(body); 46 | } else { 47 | Expect(body).to.deep.equal(expectedBody); 48 | } 49 | } 50 | done(); 51 | } 52 | } 53 | 54 | var failResponse = function(statusCode, done) { 55 | return function(err, res, body) { 56 | Expect(err).to.equal(null); 57 | Expect(res.statusCode).to.equal(statusCode); 58 | Expect(body.error).to.not.equal(null); 59 | done(); 60 | } 61 | } 62 | 63 | describe('Petstore', function() { 64 | this.timeout(4000); 65 | before(function(done) { 66 | Petstore.listen(3333, done); 67 | }); 68 | 69 | it('should allow new users', function(done) { 70 | Request.post({ 71 | url: BASE_URL + '/users', 72 | body: USER_1, 73 | json: true 74 | }, successResponse(function(user) { 75 | Expect(user._id.length).to.be.above(0); 76 | Expect(user.password_hash.length).to.be.above(0); 77 | Expect(user.username).to.equal(USER_1.username); 78 | Expect(user.password).to.equal(undefined); 79 | USER_1._id = user._id; 80 | }, done)); 81 | }); 82 | 83 | it('should not show password_hash', function(done) { 84 | Request.get({ 85 | url: BASE_URL + '/users', 86 | json: true, 87 | }, successResponse(function(users) { 88 | Expect(users[0].password_hash).to.equal(undefined); 89 | var expected = _.extend({}, USER_1); 90 | delete expected.password; 91 | Expect(users).to.deep.equal([expected]) 92 | }, done)) 93 | }); 94 | 95 | it('should not allow duplicate user names', function(done) { 96 | Request.post({ 97 | url: BASE_URL + '/users', 98 | body: {username: USER_1.username, password: 'swordfish'}, 99 | json: true 100 | }, failResponse(500, done)); 101 | }) 102 | 103 | it('should allow a second user', function(done) { 104 | Request.post({ 105 | url: BASE_URL + '/users', 106 | body: USER_2, 107 | json: true 108 | }, successResponse(function(user) { 109 | USER_2._id = user._id; 110 | Expect(user.username).to.equal(USER_2.username); 111 | }, done)); 112 | }) 113 | 114 | it('should allow new pets', function(done) { 115 | CurrentPets.push(PETS[1]); 116 | Request.post({ 117 | url: BASE_URL + '/pets/1', 118 | body: {id: 1, name: PETS[1].name, animalType: PETS[1].animalType}, 119 | headers: USER_1, 120 | json: true 121 | }, successResponse(function(pet) { 122 | PETS[1]._id = pet._id; 123 | Expect(pet).to.deep.equal(PETS[1]); 124 | }, done)); 125 | }) 126 | 127 | it('should allow a second pet', function(done) { 128 | CurrentPets.push(PETS[2]); 129 | Request.post({ 130 | url: BASE_URL + '/pets/2', 131 | body: {id: 2, name: PETS[2].name, animalType: PETS[2].animalType}, 132 | headers: USER_2, 133 | json: true 134 | }, successResponse(function(pet) { 135 | Expect(pet.name).to.equal(PETS[2].name); 136 | }, done)); 137 | }) 138 | 139 | it('should upsert on post', function(done) { 140 | PETS[2].name = 'Goose'; 141 | Request.post({ 142 | url: BASE_URL + '/pets/' + PETS[2].id, 143 | body: PETS[2], 144 | headers: USER_2, 145 | json: true 146 | }, successResponse(function(pet) { 147 | PETS[2]._id = pet._id; 148 | Expect(pet).to.deep.equal(PETS[2]); 149 | }, done)) 150 | }) 151 | 152 | it('should not allow modifications from wrong user', function(done) { 153 | Request({ 154 | method: 'patch', 155 | url: BASE_URL + '/pets/1', 156 | headers: USER_2, 157 | json: true 158 | }, failResponse(404, done)); // TODO: should be 401 159 | }) 160 | 161 | it('should allow modifications to pet', function(done) { 162 | PETS[1].name = 'Loosey'; 163 | Request({ 164 | method: 'patch', 165 | url: BASE_URL + '/pets/1', 166 | headers: USER_1, 167 | body: {name: PETS[1].name}, 168 | json: true 169 | }, successResponse(done)); 170 | }) 171 | 172 | it('should allow searching', function(done) { 173 | Request.get({ 174 | url: BASE_URL + '/search/pets', 175 | qs: {q: 'oose'}, 176 | json: true 177 | }, successResponse([PETS[1], PETS[2]], done)) 178 | }) 179 | 180 | it('should not allow duplicate pets', function(done) { 181 | Request.post({ 182 | url: BASE_URL + '/pets', 183 | body: {id: 1, name: 'Goose'}, 184 | headers: USER_1, 185 | json: true 186 | }, failResponse(500, done)) 187 | }) 188 | 189 | it('should not allow new pets without auth', function(done) { 190 | Request.post({ 191 | url: BASE_URL + '/pets', 192 | body: {id: 55, name: 'Goose'}, 193 | json: true 194 | }, failResponse(401, done)); 195 | }) 196 | 197 | it('should not allow deletes without auth', function(done) { 198 | Request({ 199 | method: 'delete', 200 | url: BASE_URL + '/pets/1', 201 | json: true 202 | }, failResponse(401, done)); 203 | }) 204 | 205 | it('should not allow deletes from wrong user', function(done) { 206 | Request({ 207 | method: 'delete', 208 | url: BASE_URL + '/pets/1', 209 | headers: USER_2, 210 | json: true 211 | }, failResponse(404, done)); // TODO: should return 401 212 | }); 213 | 214 | it('should not reflect bad delete', function(done) { 215 | Request.get({ 216 | url: BASE_URL + '/pets/1', 217 | json: true 218 | }, successResponse(PETS[1], done)); 219 | }) 220 | 221 | it('should not allow deletes with wrong password', function(done) { 222 | Request({ 223 | method: 'delete', 224 | url: BASE_URL + '/pets/1', 225 | headers: { 226 | username: USER_1.username, 227 | password: USER_2.password 228 | }, 229 | json: true 230 | }, failResponse(401, done)); 231 | }) 232 | 233 | it('should allow deletes from owner', function(done) { 234 | CurrentPets.shift(); 235 | Request({ 236 | method: 'delete', 237 | url: BASE_URL + '/pets/1', 238 | headers: USER_1, 239 | json: true 240 | }, successResponse(null, done)); 241 | }) 242 | 243 | it('should allow batched adds of pets', function(done) { 244 | CurrentPets = CurrentPets.concat([PETS[3], PETS[4], PETS[5]]); 245 | Request.post({ 246 | url: BASE_URL + '/pets', 247 | headers: USER_1, 248 | body: [PETS[3], PETS[4], PETS[5]], 249 | json: true 250 | }, successResponse(function(pets) { 251 | Expect(pets.length).to.equal(3); 252 | pets.forEach(function(pet) { 253 | PETS.filter(function(p) {return p.name === pet.name})[0]._id = pet._id; 254 | }) 255 | }, done)); 256 | }) 257 | 258 | it('should allow batched modification of pets', function(done) { 259 | CurrentPets.forEach(function(pet) { 260 | if (pet.owner == USER_1.username && pet.animalType === 'cat') pet.animalType = 'dog'; 261 | }); 262 | Request({ 263 | method: 'patch', 264 | url: BASE_URL + '/pets', 265 | headers: USER_1, 266 | qs: { 267 | animalType: 'cat' 268 | }, 269 | body: { 270 | animalType: 'dog' 271 | }, 272 | json: true 273 | }, successResponse(null, done)) 274 | }) 275 | 276 | it('should reflect batched modification', function(done) { 277 | Request.get({ 278 | url: BASE_URL + '/pets', 279 | qs: { 280 | animalType: 'dog' 281 | }, 282 | json: true 283 | }, successResponse(function(pets) { 284 | var expected = CurrentPets.filter(function(pet) {return pet.animalType === 'dog'}); 285 | Expect(pets.length).to.equal(expected.length) 286 | }, done)) 287 | }) 288 | 289 | it('should support pet_count', function(done) { 290 | Request.get({ 291 | url: BASE_URL + '/pet_count', 292 | json: true 293 | }, successResponse({count: CurrentPets.length}, done)); 294 | }); 295 | 296 | it('should support mapItem to get pet types', function(done) { 297 | Request.get({ 298 | url: BASE_URL + '/pet_types', 299 | json: true, 300 | }, successResponse(CurrentPets.map(function(pet) {return pet.animalType}), done)); 301 | }) 302 | 303 | it('should allow adding a vaccination', function(done) { 304 | PETS[3].vaccinations = VACCINATIONS; 305 | Request.patch({ 306 | url: BASE_URL + '/pets/3', 307 | headers: USER_1, 308 | body: {vaccinations: VACCINATIONS}, 309 | json: true, 310 | }, successResponse(done)) 311 | }) 312 | 313 | it('should reflect added vaccination', function(done) { 314 | Request.get({ 315 | url: BASE_URL + '/pets/3', 316 | json: true, 317 | }, successResponse(PETS[3], done)); 318 | }) 319 | 320 | it('should allow batched deletes of pets', function(done) { 321 | CurrentPets = CurrentPets.filter(function(pet) {return pet.animalType !== 'dog'}); 322 | Request({ 323 | method: 'delete', 324 | url: BASE_URL + '/pets', 325 | headers: USER_1, 326 | qs: { 327 | animalType: "dog" 328 | }, 329 | json: true 330 | }, successResponse(null, done)) 331 | }) 332 | }) 333 | --------------------------------------------------------------------------------