├── .nvmrc ├── lib ├── index.js └── Resource.js ├── .coveralls.yml ├── test ├── mocha.opts ├── fixture │ ├── conf.json │ ├── index.js │ └── BookSchema.js └── Resource.js ├── .vimrc ├── esdoc.json ├── .npmignore ├── .gitignore ├── .travis.yml ├── .eslintrc.js ├── vagrant_bootstrap.sh ├── LICENSE ├── CHANGELOG.md ├── package.json ├── examples ├── extend-existing-middleware-functionality.js ├── quick-bootstrap.js └── add-middleware-common-to-all-endpoints.js ├── Vagrantfile └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 6.9.1 2 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | exports.Resource = require('./Resource'); 2 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: Kx10XQEKgEoF4zIjkQSpHIrboR8mulMUC 2 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --recursive 2 | --reporter spec 3 | --ui bdd 4 | -------------------------------------------------------------------------------- /.vimrc: -------------------------------------------------------------------------------- 1 | set shiftwidth=4 2 | set softtabstop=4 3 | set tabstop=4 4 | set ts=4 5 | set sw=4 6 | -------------------------------------------------------------------------------- /esdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "./lib", 3 | "destination": "./doc", 4 | "lint": true 5 | } 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | doc/ 2 | coverage/ 3 | examples/ 4 | src/ 5 | test/ 6 | coffeelint.json 7 | LICENSE 8 | vagrant_bootstrap.sh 9 | Vagrantfile 10 | -------------------------------------------------------------------------------- /test/fixture/conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "mongo": { 3 | "host": "localhost", 4 | "port": 27017, 5 | "db": "mortimer" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | node_modules 15 | doc 16 | .vagrant 17 | coverage 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6.3.1" 4 | services: 5 | - mongodb 6 | after_success: 7 | - npm run coveralls 8 | branches: 9 | only: 10 | - master 11 | -------------------------------------------------------------------------------- /test/fixture/index.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const conf = require('./conf'); 4 | const BookSchema = require('./BookSchema'); 5 | 6 | mongoose.connect(`mongodb://${conf.mongo.host}:${conf.mongo.port}/${conf.mongo.db}`); 7 | 8 | // Public API. 9 | exports.Book = mongoose.model('Book', BookSchema); 10 | -------------------------------------------------------------------------------- /test/fixture/BookSchema.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Mixed = mongoose.Schema.Types.Mixed; 4 | 5 | const BookSchema = new mongoose.Schema({ 6 | title: {type: String, required: true}, 7 | author: {type: String, required: true}, 8 | details: {type: Mixed} 9 | }); 10 | 11 | // Public API. 12 | module.exports = BookSchema; 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "rules": { 9 | "indent": [ 10 | "error", 11 | 4 12 | ], 13 | "linebreak-style": [ 14 | "error", 15 | "unix" 16 | ], 17 | "quotes": [ 18 | "error", 19 | "single" 20 | ], 21 | "semi": [ 22 | "error", 23 | "always" 24 | ] 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /vagrant_bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Install mongodb. 4 | sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv EA312927 5 | echo "deb http://repo.mongodb.org/apt/ubuntu precise/mongodb-org/3.2 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.2.list 6 | sudo apt-get update 7 | sudo apt-get install -y mongodb-org-server mongodb-org-shell 8 | 9 | # Install nvm 10 | wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.30.1/install.sh | bash 11 | echo ". ~/.nvm/nvm.sh" >> ~/.bash_profile 12 | source ~/.bash_profile 13 | 14 | # Install latest version of node 15 | nvm install 6.9.1 16 | 17 | # Install package dependencies. 18 | cd /vagrant; nvm use; npm install 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Alexandru Topliceanu (alexandru.topliceanu@gmail.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 2.1.0 - Oct 20, 2016 4 | * Add optional log function parameter to the constructor. This allows clients to dirrect errors to their own logging infrastructure 5 | 6 | ## Version 2.0.1 - Aug 28, 2016 7 | * Fix critical bug, the implementation code base was not bundled in the npm package because of the way .npmignore works with .gitignore in node v6. 8 | 9 | ## Version 2.0.0 - Aug 20, 2016 10 | * Port the codese to ES2015. Compatiblity with node version <6 was dropped, please use v1.1.0 for this. 11 | 12 | ## Version 1.1.0 - Jan 19, 2015 13 | * Implement `PUT //:Id` to completely replace document. 14 | * Implement `PATCH /` to update a set of selected documents. 15 | * Implement `DELETE /` to remove a set of selected documents. 16 | * Improve documentation accross the board. 17 | * Update examples and README.md to showcase the new endpoints. 18 | 19 | ## Version 1.0.1 - Jan 17, 2015 20 | * Patch error capturing and reporting by the api. 21 | * Improve methods documentation 22 | 23 | ## Version 1.0.0 - Jan 13, 2015 24 | * Bump major version! This version is no longer compatible with previous ones! 25 | * Huge rethink of the module to improve extendability. 26 | * Port the code to coffeescript for better maintanability. 27 | * Update to latest versions of mongoose and express. 28 | * Add code documentations. 29 | * Add code coverage. 30 | * Add examples. 31 | 32 | ## Version 0.1.9 - Dec 8, 2012 33 | * A log time ago! 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mortimer", 3 | "version": "2.1.0", 4 | "description": "rest interface for mongoose models, built on top of express", 5 | "homepage": "http://github.com/topliceanu/mortimer", 6 | "license": "MIT", 7 | "keywords": [ 8 | "rest", 9 | "mongoose", 10 | "mongodb", 11 | "express" 12 | ], 13 | "author": "alexandru topliceanu (http://alexandrutopliceanu.ro)", 14 | "repository": { 15 | "type": "git", 16 | "url": "git://github.com/topliceanu/mortimer.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/topliceanu/mortimer/issues" 20 | }, 21 | "main": "./lib/index.js", 22 | "scripts": { 23 | "test": "./node_modules/.bin/mocha", 24 | "coverage": "./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha", 25 | "coveralls": "./node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js", 26 | "lint": "./node_modules/.bin/eslint ./lib ./test ./examples", 27 | "doc": "./node_modules/.bin/esdoc -c esdoc.json" 28 | }, 29 | "dependencies": {}, 30 | "devDependencies": { 31 | "body-parser": "1.15.2", 32 | "chai": "3.5.0", 33 | "coveralls": "2.11.12", 34 | "esdoc": "0.4.8", 35 | "eslint": "3.3.1", 36 | "express": "4.14.0", 37 | "istanbul": "0.4.4", 38 | "mocha": "3.0.2", 39 | "mocha-lcov-reporter": "1.2.0", 40 | "mongoose": "4.5.9", 41 | "supertest": "2.0.0" 42 | }, 43 | "optionalDependencies": {}, 44 | "engines": { 45 | "node": ">6.3.1" 46 | }, 47 | "config": {} 48 | } 49 | -------------------------------------------------------------------------------- /examples/extend-existing-middleware-functionality.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This examples shows how to extend functionality of the basic endpoints. 3 | * 4 | * Mortimer is built on the belief that you should be aware of the 5 | * implementation of the libraries you use, so that you can extend their 6 | * functionality to better suit your needs. In this it is similar to Backbone.js. 7 | * 8 | * In this example we want to add basic validation to the input json payload. 9 | * We achive this by extending the `createDoc()` method which returns a list 10 | * of middleware functions to include our sanityCheck() middleware just before 11 | * storing the data in mongodb. 12 | * 13 | * We then simply reuse all the other middleware provided by mortimer. 14 | * Mortimer is compatible with connect/express middleware so you can mix and 15 | * match your middleware, with connect, with other third parties and with 16 | * middleware created by you. You are also encouraged to override mortimer 17 | * middleware as needed. 18 | * 19 | * To run, simply: 20 | * $ node ./quick-bootstrap.js 21 | * 22 | * To test: 23 | * $ curl -XPOST http://localhost:3000/books -H 'Content-type: application/json' -d '{"title": "Suuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuper long title", "author": "Some Author"}' 24 | * $ {"msg":"Bad payload format"} 25 | */ 26 | 27 | const bodyParser = require('body-parser'); 28 | const express = require('express'); 29 | const mongoose = require('mongoose'); 30 | const mortimer = require('../lib/'); // require('mortimer'); 31 | 32 | 33 | // Handle connection to mongodb and data modeling. 34 | mongoose.connect('mongodb://localhost:27017/examples'); 35 | 36 | const BookSchema = new mongoose.Schema({ 37 | 'title': {type: String}, 38 | 'author': {type: String} 39 | }); 40 | const Book = mongoose.model('Book', BookSchema); 41 | 42 | 43 | // Setup http server with express. 44 | const app = express(); 45 | app.set('query parser', 'simple'); 46 | app.use(bodyParser.json()); 47 | 48 | 49 | // Extend mortimer.Resource class so that before creating a new book, 50 | // the request json payload is validated. 51 | class BookResource extends mortimer.Resource { 52 | constructor () { 53 | super(Book); 54 | } 55 | 56 | createDoc () { 57 | return [ 58 | this.namespace(), 59 | this.sanityCheck(), // this middleware is added to the original stack. 60 | this.create(), 61 | this.publish({statusCode: 201}) 62 | ]; 63 | } 64 | 65 | /* 66 | * Returns a middleware function to check if the payload is correct: 67 | * author and title fields must be String smaller than 100 characters. 68 | * It will return 400 Bad Payload otherwise. 69 | */ 70 | sanityCheck () { 71 | return function (req, res, next) { 72 | if (req.body.title && 73 | req.body.title.length < 100 && 74 | req.body.author && 75 | req.body.author.length < 100) { 76 | // 'Valid payload' 77 | next(); 78 | } 79 | else { 80 | res.status(400).send({msg: 'Bad payload format'}); 81 | } 82 | }; 83 | } 84 | } 85 | 86 | 87 | const resource = new BookResource(); 88 | app.post('/books', resource.createDoc()); 89 | 90 | 91 | // Start the http server on http://localhost:3000/ 92 | app.listen(3000, 'localhost'); 93 | -------------------------------------------------------------------------------- /examples/quick-bootstrap.js: -------------------------------------------------------------------------------- 1 | /* This examples show how easy it is to expose a simple CRUD REST interface on 2 | * top of a fictional books collection. 3 | * 4 | * Mortimer believes that route paths should be determined by the client 5 | * (ie. you) so it will not enforce andy routing pattern, it will only expose 6 | * handler methods. However please note that the :bookId token is de default 7 | * value for matching document ids in urls. It can however be changed, please 8 | * refer to the Resource constructor in the documentation. 9 | * 10 | * Note! That the PUT verb is not defined. PUT implies replacing the resource 11 | * completely, something that is more contrived to achieve with mongoose. 12 | * Instead PATCH acts as regular update, simply merging the payload with the 13 | * existing resource. 14 | * 15 | * To run, simply: 16 | * $ node ./quick-bootstrap.js 17 | * 18 | * To test: 19 | * $ curl -XPOST http://localhost:3000/books -H 'Content-type: application/json' -d '{"title": "Brothers Karamazov", "author": "Feodor Dostoevsky"}' 20 | * $ {"meta":{},"data":{"__v":0,"title":"Brothers Karamazov","author":"Feodor Dostoevsky","_id":"54b8d3f6c9f63bce07386878"}} 21 | * 22 | * $ curl -XGET http://localhost:3000/books 23 | * $ {"meta":{},"data":[{"_id":"54b8d3f6c9f63bce07386878","title":"Brothers Karamazov","author":"Fyodor Dostoevsky","__v":0}]} 24 | * 25 | * $ curl -XGET http://localhost:3000/books/54b8d3f6c9f63bce07386878 26 | * $ {"meta":{},"data":{"_id":"54b8d3f6c9f63bce07386878","title":"Brothers Karamazov","author":"Feodor Dostoevsky","__v":0}} 27 | * 28 | * $ curl -XPOST http://localhost:3000/books -H 'Content-type: application/json' -d '{"title": "Crime and Punishment", "author": "Feodor Dostoevsky"}' 29 | * $ {"meta":{},"data":{"__v":0,"title":"Brothers Karamazov","author":"Feodor Dostoevsky","_id":"54b8d3f6c9f63bce07386124"}} 30 | * 31 | * $ curl -XGET http://localhost:3000/books/count 32 | * $ {"meta":{},"data":2} 33 | * 34 | * $ curl -XPATCH http://localhost:3000/books/54b8d3f6c9f63bce07386878 -H 'Content-type: application/json' -d '{"author": "Fyodor Dostoevsky"}' 35 | * $ {"meta":{},"data":{"_id":"54b8d3f6c9f63bce07386878","title":"Brothers Karamazov","author":"Fyodor Dostoevsky","__v":1}} 36 | * 37 | * $ curl -XPUT http://localhost:3000/books/54b8d3f6c9f63bce07386124 -H 'Content-type: application/json' -d '{"author": "Fyodor Dostoevsky", "title": "Crime & Punishment"}' 38 | * $ {"meta":{},"data":{"_id":"54b8d3f6c9f63bce07386124","title":"Crime & Punishement","author":"Fyodor Dostoevsky","__v":0}} 39 | * 40 | * $ curl -XPATCH http://localhost:3000/books -H 'Content-type: application/json' -d '{"author": "Greatest Russian Author Ever!"}' 41 | * $ {"meta":{}} 42 | * 43 | * $ curl -XDELETE http://localhost:3000/books 44 | */ 45 | 46 | 47 | const bodyParser = require('body-parser'); 48 | const express = require('express'); 49 | const mongoose = require('mongoose'); 50 | const mortimer = require('../lib/'); // require('mortimer'); 51 | 52 | 53 | // Handle connection to mongodb and data modeling. 54 | mongoose.connect('mongodb://localhost:27017/examples'); 55 | 56 | const BookSchema = new mongoose.Schema({ 57 | 'title': {type: String}, 58 | 'author': {type: String} 59 | }); 60 | const Book = mongoose.model('Book', BookSchema); 61 | 62 | 63 | // Setup http server with express. 64 | const app = express(); 65 | app.set('query parser', 'simple'); 66 | app.use(bodyParser.json()); 67 | 68 | 69 | // Setup mortimer endpoints. 70 | const resource = new mortimer.Resource(Book); 71 | app.post('/books', resource.createDoc()); 72 | app.get('/books', resource.readDocs()); 73 | app.get('/books/count', resource.countDocs()); 74 | app.patch('/books', resource.patchDocs()); 75 | app.delete('/books', resource.removeDocs()); 76 | app.get('/books/:bookId', resource.readDoc()); 77 | app.patch('/books/:bookId', resource.patchDoc()); 78 | app.put('/books/:bookId', resource.putDoc()); 79 | app.delete('/books/:bookId', resource.removeDoc()); 80 | 81 | 82 | // Start the http server on http://localhost:3000/ 83 | app.listen(3000, 'localhost'); 84 | -------------------------------------------------------------------------------- /examples/add-middleware-common-to-all-endpoints.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This example shows how to add common middleware to all endpoints. 3 | * 4 | * Because express works with arrays of middleware, changing endpoint 5 | * functionality is as easy as splicing and dicing arrays of middleware. 6 | * To achive this, we will extend mortimer.Resource to add a before() hook 7 | * method to each endpoint. This injects an array of custom middleware 8 | * in front of all middleware for existing endpoints. 9 | * 10 | * In this particular example, we want to count the requests to each endpoint. 11 | * Why would you want to do that? Hey, it's just an example! But seriously, 12 | * you can use this to add authentication, rate limiting, payload validation, 13 | * output sanitation, etc. Mortimer provides the backbone for all that. 14 | * 15 | * To run, simply: 16 | * $ node ./quick-bootstrap.js 17 | * 18 | * To test: 19 | * $ curl -XPOST http://localhost:3000/books -H 'Content-type: application/json' -d '{"title": "Brothers Karamazov", "author": "Feodor Dostoevsky"}' 20 | * $ {"meta":{},"data":{"__v":0,"title":"Brothers Karamazov","author":"Feodor Dostoevsky","_id":"54b8e92691dd770c0a3bf9be"}} 21 | * $ Request counters { createDoc: 1, readDocs: 0, readDoc: 0, patchDoc: 0, removeDoc: 0 } 22 | */ 23 | 24 | const bodyParser = require('body-parser'); 25 | const express = require('express'); 26 | const mongoose = require('mongoose'); 27 | const mortimer = require('../lib/'); // require('mortimer'); 28 | 29 | 30 | // Handle connection to mongodb and data modeling. 31 | mongoose.connect('mongodb://localhost:27017/examples'); 32 | 33 | const BookSchema = new mongoose.Schema({ 34 | 'title': {type: String}, 35 | 'author': {type: String} 36 | }); 37 | 38 | const Book = mongoose.model('Book', BookSchema); 39 | 40 | 41 | // Setup http server with express. 42 | const app = express(); 43 | app.set('query parser', 'simple'); 44 | app.use(bodyParser.json()); 45 | 46 | 47 | // Extend mortimer.Resource class to add a before hook. 48 | class ResourceWithHooks extends mortimer.Resource { 49 | // By default this method adds no extra middleware, but subclasses can 50 | // implement this to add their own custom functionality. 51 | before () { 52 | return []; 53 | } 54 | 55 | // All endpoints are being overriden to add the before() method. 56 | 57 | createDoc () { 58 | const original = super.createDoc(); 59 | original.unshift(this.before('createDoc')); 60 | return original; 61 | } 62 | 63 | readDocs () { 64 | const original = super.readDocs(); 65 | original.unshift(this.before('readDocs')); 66 | return original; 67 | } 68 | 69 | readDoc () { 70 | const original = super.readDoc(); 71 | original.unshift(this.before('readDoc')); 72 | return original; 73 | } 74 | 75 | patchDoc () { 76 | const original = super.patchDoc(); 77 | original.unshift(this.before('patchDoc')); 78 | return original; 79 | } 80 | 81 | removeDoc () { 82 | const original = super.removeDoc(); 83 | original.unshift(this.before('removeDoc')); 84 | return original; 85 | } 86 | } 87 | 88 | 89 | // Extend ResourceWithHooks to add a counter middleware before all endpoints. 90 | class BookResource extends ResourceWithHooks { 91 | constructor () { 92 | super(Book); 93 | } 94 | 95 | // This method implements the counting routine. 96 | before (tag) { 97 | if (!this.counters) { 98 | this.counters = {}; 99 | } 100 | if (!this.counters[tag]) { 101 | this.counters[tag] = 0; 102 | } 103 | var that = this; 104 | return function (req, res, next) { 105 | that.counters[tag] += 1; 106 | next(); 107 | }; 108 | } 109 | } 110 | 111 | 112 | // Setup mortimer endpoints. 113 | const resource = new BookResource(); 114 | app.post('/books', resource.createDoc()); 115 | app.get('/books', resource.readDocs()); 116 | app.get('/books/:bookId', resource.readDoc()); 117 | app.patch('/books/:bookId', resource.patchDoc()); 118 | app.delete('/books/:bookId', resource.removeDoc()); 119 | 120 | 121 | // Start the http server on http://localhost:3000/ 122 | app.listen(3000, 'localhost'); 123 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # Vagrantfile API/syntax version. Don't touch unless you know what you're doing! 5 | VAGRANTFILE_API_VERSION = "2" 6 | 7 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 8 | # All Vagrant configuration is done here. The most common configuration 9 | # options are documented and commented below. For a complete reference, 10 | # please see the online documentation at vagrantup.com. 11 | 12 | # Every Vagrant virtual environment requires a box to build off of. 13 | #config.vm.box = "base" 14 | config.vm.box = "ubuntu/trusty64" 15 | 16 | # Disable automatic box update checking. If you disable this, then 17 | # boxes will only be checked for updates when the user runs 18 | # `vagrant box outdated`. This is not recommended. 19 | # config.vm.box_check_update = false 20 | 21 | # Create a forwarded port mapping which allows access to a specific port 22 | # within the machine from a port on the host machine. In the example below, 23 | # accessing "localhost:8080" will access port 80 on the guest machine. 24 | # config.vm.network "forwarded_port", guest: 80, host: 8080 25 | 26 | # Create a private network, which allows host-only access to the machine 27 | # using a specific IP. 28 | # config.vm.network "private_network", ip: "192.168.33.10" 29 | config.vm.network "private_network", ip: "192.168.33.15" 30 | 31 | # Create a public network, which generally matched to bridged network. 32 | # Bridged networks make the machine appear as another physical device on 33 | # your network. 34 | # config.vm.network "public_network" 35 | 36 | # If true, then any SSH connections made will enable agent forwarding. 37 | # Default value: false 38 | # config.ssh.forward_agent = true 39 | 40 | # Share an additional folder to the guest VM. The first argument is 41 | # the path on the host to the actual folder. The second argument is 42 | # the path on the guest to mount the folder. And the optional third 43 | # argument is a set of non-required options. 44 | # config.vm.synced_folder "../data", "/vagrant_data" 45 | 46 | # Provider-specific configuration so you can fine-tune various 47 | # backing providers for Vagrant. These expose provider-specific options. 48 | # Example for VirtualBox: 49 | # 50 | # config.vm.provider "virtualbox" do |vb| 51 | # # Don't boot with headless mode 52 | # vb.gui = true 53 | ## 54 | # # Use VBoxManage to customize the VM. For example to change memory: 55 | # vb.customize ["modifyvm", :id, "--memory", "1024"] 56 | # end 57 | config.vm.provider "virtualbox" do |vb| 58 | # Increase memory of vm. 59 | vb.memory = 2048 # MB of RAM 60 | end 61 | 62 | # 63 | # View the documentation for the provider you're using for more 64 | # information on available options. 65 | 66 | # Enable provisioning with CFEngine. CFEngine Community packages are 67 | # automatically installed. For example, configure the host as a 68 | # policy server and optionally a policy file to run: 69 | # 70 | # config.vm.provision "cfengine" do |cf| 71 | # cf.am_policy_hub = true 72 | # # cf.run_file = "motd.cf" 73 | # end 74 | # 75 | # You can also configure and bootstrap a client to an existing 76 | # policy server: 77 | # 78 | # config.vm.provision "cfengine" do |cf| 79 | # cf.policy_server_address = "10.0.2.15" 80 | # end 81 | 82 | # Enable provisioning with Puppet stand alone. Puppet manifests 83 | # are contained in a directory path relative to this Vagrantfile. 84 | # You will need to create the manifests directory and a manifest in 85 | # the file default.pp in the manifests_path directory. 86 | # 87 | # config.vm.provision "puppet" do |puppet| 88 | # puppet.manifests_path = "manifests" 89 | # puppet.manifest_file = "default.pp" 90 | # end 91 | 92 | # Enable provisioning with chef solo, specifying a cookbooks path, roles 93 | # path, and data_bags path (all relative to this Vagrantfile), and adding 94 | # some recipes and/or roles. 95 | # 96 | # config.vm.provision "chef_solo" do |chef| 97 | # chef.cookbooks_path = "../my-recipes/cookbooks" 98 | # chef.roles_path = "../my-recipes/roles" 99 | # chef.data_bags_path = "../my-recipes/data_bags" 100 | # chef.add_recipe "mysql" 101 | # chef.add_role "web" 102 | # 103 | # # You may also specify custom JSON attributes: 104 | # chef.json = { mysql_password: "foo" } 105 | # end 106 | 107 | # Enable provisioning with chef server, specifying the chef server URL, 108 | # and the path to the validation key (relative to this Vagrantfile). 109 | # 110 | # The Opscode Platform uses HTTPS. Substitute your organization for 111 | # ORGNAME in the URL and validation key. 112 | # 113 | # If you have your own Chef Server, use the appropriate URL, which may be 114 | # HTTP instead of HTTPS depending on your configuration. Also change the 115 | # validation key to validation.pem. 116 | # 117 | # config.vm.provision "chef_client" do |chef| 118 | # chef.chef_server_url = "https://api.opscode.com/organizations/ORGNAME" 119 | # chef.validation_key_path = "ORGNAME-validator.pem" 120 | # end 121 | 122 | config.vm.provision :shell, path: "./vagrant_bootstrap.sh", privileged: false 123 | 124 | # If you're using the Opscode platform, your validator client is 125 | # ORGNAME-validator, replacing ORGNAME with your organization name. 126 | # 127 | # If you have your own Chef Server, the default validation client name is 128 | # chef-validator, unless you changed the configuration. 129 | # 130 | # chef.validation_client_name = "ORGNAME-validator" 131 | end 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mortimer = MOngoose ResT 2 | 3 | ## Gist 4 | 5 | **Mortimer** is an extendible REST interface for mongoose models, designed for the entire project lifecycle: from fast prototyping to advanced custom functionality. 6 | 7 | ## Status 8 | 9 | [![NPM](https://nodei.co/npm/mortimer.png?downloads=true&stars=true)](https://nodei.co/npm/mortimer/) 10 | 11 | [![NPM](https://nodei.co/npm-dl/mortimer.png?months=12)](https://nodei.co/npm-dl/mortimer/) 12 | 13 | | Indicator | | 14 | |:-----------------------|:-------------------------------------------------------------------------| 15 | | documentation | [topliceanu.github.io/mortimer](http://topliceanu.github.io/mortimer) ~~[hosted on coffedoc.info](http://coffeedoc.info/github/topliceanu/mortimer/master/)~~| 16 | | continuous integration | [![Build Status](https://travis-ci.org/topliceanu/mortimer.svg?branch=master)](https://travis-ci.org/topliceanu/mortimer) | 17 | | dependency management | [![Dependency Status](https://david-dm.org/topliceanu/mortimer.svg?style=flat)](https://david-dm.org/topliceanu/mortimer) [![devDependency Status](https://david-dm.org/topliceanu/mortimer/dev-status.svg?style=flat)](https://david-dm.org/topliceanu/mortimer#info=devDependencies) | 18 | | code coverage | [![Coverage Status](https://coveralls.io/repos/topliceanu/mortimer/badge.svg?branch=master)](https://coveralls.io/r/topliceanu/mortimer?branch=master) | 19 | | examples | [/examples](https://github.com/topliceanu/mortimer/tree/master/examples) | 20 | | development management | [![Stories in Ready](https://badge.waffle.io/topliceanu/mortimer.svg?label=ready&title=Ready)](http://waffle.io/topliceanu/mortimer) | 21 | | change log | [CHANGELOG](https://github.com/topliceanu/mortimer/blob/master/CHANGELOG.md) [Releases](https://github.com/topliceanu/mortimer/releases) | 22 | 23 | ## Features 24 | 25 | - Focus on extensibility. Fully plugable! 26 | - Does not depend on mongoose or express packages. It just builds arrays of middleware functions. 27 | - Easy to bootstrap a basic REST API for your models. 28 | - Supports filtering, pagination, sorting, property selection. 29 | 30 | ## Install 31 | 32 | ```shell 33 | npm install mortimer 34 | ``` 35 | 36 | ## Quick Example 37 | 38 | ```javascript 39 | var bodyParser = require('body-parser'); 40 | var express = require('express'); 41 | var mongoose = require('mongoose'); 42 | var mortimer = require('mortimer'); 43 | 44 | 45 | // Handle connection to mongodb and data modeling. 46 | mongoose.connect('mongodb://localhost:27017/examples'); 47 | 48 | var BookSchema = new mongoose.Schema({ 49 | 'title': {type: String}, 50 | 'author': {type: String} 51 | }); 52 | 53 | var Book = mongoose.model('Book', BookSchema); 54 | 55 | 56 | // Setup http server with express. 57 | var app = express(); 58 | app.set('query parser', 'simple'); 59 | app.use(bodyParser.json()); 60 | 61 | 62 | // Setup mortimer endpoints. 63 | var resource = new mortimer.Resource(Book); 64 | app.post('/books', resource.createDoc()); 65 | app.get('/books', resource.readDocs()); 66 | app.get('/books/count', resource.countDocs()); 67 | app.patch('/books', resource.patchDocs()); 68 | app.delete('/books', resource.removeDocs()); 69 | app.get('/books/:bookId', resource.readDoc()); 70 | app.patch('/books/:bookId', resource.patchDoc()); 71 | app.put('/books/:bookId', resource.putDoc()); 72 | app.delete('/books/:bookId', resource.removeDoc()); 73 | 74 | 75 | // Start the http server on http://localhost:3000/ 76 | app.listen(3000, 'localhost'); 77 | ``` 78 | 79 | ## More Examples 80 | 81 | See more in the `/examples` directory. All examples have instructions on __how to run and test them__. 82 | 83 | - if you want to quickly bootstrap a rest api, check out [this example](https://github.com/topliceanu/mortimer/blob/master/examples/quick-bootstrap.js). You can rapidly define your own routes and let mortimer handle the requests. 84 | - if you want to add middleware in front of every endpoint, check out [this example](https://github.com/topliceanu/mortimer/blob/master/examples/add-auth-to-create-endpoint.js). This can be usefull to add authentication, rate limiting, payload validation, output sanitation, etc. Mortimer is a backbone for all that. 85 | - if you want to add custom functionality to just one middleware, check out [this example](https://github.com/topliceanu/mortimer/blob/master/examples/extend-existing-middleware-functionality.js) 86 | 87 | ## Contributing 88 | 89 | 1. Contributions to this project are more than welcomed! 90 | - Anything from improving docs, code cleanup to advanced functionality is greatly appreciated. 91 | - Before you start working on an ideea, please open an issue and describe in detail what you want to do and __why it's important__. 92 | - You will get an answer in max 12h depending on your timezone. 93 | 2. Fork the repo! 94 | 3. If you use [vagrant](https://www.vagrantup.com/) then simply clone the repo into a folder then issue `$ vagrant up` 95 | - if you don't use it, please consider learning it, it's easy to install and to get started with. 96 | - If you don't use it, then you have to: 97 | - install mongodb and have it running on `localhost:27017`. 98 | - install node.js and all node packages required in development using `$ npm install` 99 | - For reference, see `./vagrant_boostrap.sh` for instructions on how to setup all dependencies on a fresh ubuntu 14.04 machine. 100 | - Run the tests to make sure you have a correct setup: `$ npm run test` 101 | 4. Create a new branch and implement your feature. 102 | - make sure you add tests for your feature. In the end __all tests have to pass__! To run test suite `$ npm run test`. 103 | - make sure test coverage does not decrease. Run `$ npm run coverage` to open a browser window with the coverage report. 104 | - make sure you document your code and generated code looks ok. Run `$ npm run doc` to re-generate the documentation. 105 | - make sure code is linted (and tests too). Run `$ npm run lint` 106 | - submit a pull request with your code. 107 | - hit me up for a code review! 108 | 5. Have my kindest thanks for making this project better! 109 | 110 | ## Licence 111 | 112 | (The MIT License) 113 | 114 | Copyright (c) Alexandru Topliceanu (alexandru.topliceanu@gmail.com) 115 | 116 | Permission is hereby granted, free of charge, to any person obtaining 117 | a copy of this software and associated documentation files (the 118 | 'Software'), to deal in the Software without restriction, including 119 | without limitation the rights to use, copy, modify, merge, publish, 120 | distribute, sublicense, and/or sell copies of the Software, and to 121 | permit persons to whom the Software is furnished to do so, subject to 122 | the following conditions: 123 | 124 | The above copyright notice and this permission notice shall be 125 | included in all copies or substantial portions of the Software. 126 | 127 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 128 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 129 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 130 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 131 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 132 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 133 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 134 | -------------------------------------------------------------------------------- /lib/Resource.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for all Resources. 3 | * 4 | * @example How to quickly generate rest endpoint for your mongoose model. 5 | * // ... 6 | * var bookResource = new Resource(BookModel); 7 | * // ... 8 | * app.get('/books', bookResource.readDocs()) 9 | * app.post('/books', bookResource.createDoc()) 10 | * app.delete('/books', bookResource.removeDocs()) 11 | * app.get('/books/:bookId', bookResource.readDoc()) 12 | * app.patch('/books/:bookId', bookResource.patchDoc()) 13 | * app.put('/books/:bookId', bookResource.putDoc()) 14 | * app.delete('/books/:bookId', bookResource.removeDoc()) 15 | * // ... 16 | */ 17 | class Resource { 18 | 19 | /* 20 | * Builds a new Resource instance given a mongoose.Model class. 21 | * 22 | * @param Model {Object} instance of mongoose.Model 23 | * @param {Object} options 24 | * @option options {String} modelName useful for composing the modelKey 25 | * @option options {String} modelKey useful for identifying model id in routes 26 | * @option options {Number} defaultPageSize pagination default page size 27 | * @option options {String} namespace placeholder for custom data and methods. 28 | * @option options {Function} log is a function(level, message, args...) {} 29 | */ 30 | constructor (Model, options = {}) { 31 | if (!this.isMongooseModel(Model)) { 32 | throw new Error('Resource expected an instance of mongoose.Model'); 33 | } 34 | 35 | // @property {Object} Model instance of mongoose.Model 36 | this.Model = Model; 37 | 38 | // @property {String} modelName override the name of the model 39 | this.modelName = options.modelName || this.Model.modelName.toLowerCase(); 40 | 41 | // @property {String} modelKey override :modelId key in routes. 42 | this.modelKey = options.modelKey || `${this.modelName}Id`; 43 | 44 | // @property {Array} reservedQueryParams ignored by filters. 45 | this.reservedQueryParams = ['_fields', '_skip', '_limit', '_sort']; 46 | 47 | // @property {Array} supportedQueryOperators query operators. 48 | this.supportedQueryOperators = ['eq', 'ne', 'lt', 'gt', 'lte', 49 | 'gte', 'regex', 'in']; 50 | 51 | // @property {Number} defaultPageSize default page size when paginating. 52 | this.defaultPageSize = options.defaultPageSize || 10; 53 | 54 | // @property {String} ns custom mortimer data attached to http.Request. 55 | this.ns = options.namespace || 'mrt'; 56 | 57 | // @property {Function} a function to log messages. Defaults to noop. 58 | if (this.is('Function', options.log)) { 59 | this.log = options.log; 60 | } 61 | else { 62 | this.log = () => {}; // noop. 63 | } 64 | } 65 | 66 | // GENERIC ENDPOINTS 67 | 68 | /** 69 | * Middleware stack which creates a new document. 70 | * 71 | * @example Bind this middleware to an express endpoint. 72 | * var bookResource = new Resource(BookModel); 73 | * // ... 74 | * app.post('/books', bookResource.createDoc()); 75 | * 76 | * @return {Array} list of express compatible middleware functions. 77 | */ 78 | createDoc () { 79 | return [ 80 | this.namespace(), 81 | this.create(), 82 | this.publish({statusCode: 201}) 83 | ]; 84 | } 85 | 86 | /** 87 | * Middleware stack which reads a document by it's id. 88 | * 89 | * @example Bind this middleware to an express endpoint. 90 | * var bookResource = new Resource(BookModel); 91 | * // ... 92 | * app.get('/books/:bookId', bookResource.readDoc()); 93 | * 94 | * @return {Array} list of express compatible middleware functions. 95 | */ 96 | readDoc () { 97 | return [ 98 | this.namespace(), 99 | this.read(), 100 | this.fields(), 101 | this.execute(), 102 | this.publish({statusCode: 200}) 103 | ]; 104 | } 105 | 106 | /** 107 | * Middleware stack which updates a document by the provided id. 108 | * 109 | * @example Bind this middleware to an express endpoint. 110 | * var bookResource = new Resource(BookModel); 111 | * // ... 112 | * app.patch('/books/:bookId', bookResource.patchDoc()); 113 | * 114 | * @return {Array} list of express compatible middleware functions. 115 | */ 116 | patchDoc () { 117 | return [ 118 | this.namespace(), 119 | this.read(), 120 | this.execute(), 121 | this.patch(), 122 | this.publish({statusCode: 200}) 123 | ]; 124 | } 125 | 126 | /** 127 | * Middleware stack which replaces existing resource with the provided 128 | * request body. 129 | * 130 | * @example Bind this middleware to an express endpoint. 131 | * var bookResource = new Resource(BookModel); 132 | * // ... 133 | * app.put('/books/:bookId', bookResource.putDoc()); 134 | */ 135 | putDoc () { 136 | return [ 137 | this.namespace(), 138 | this.read(), 139 | this.execute(), 140 | this.put(), 141 | this.publish({statusCode: 200}) 142 | ]; 143 | } 144 | 145 | /** 146 | * Middleware stack which removes a document specified by id. 147 | * 148 | * @example Bind this middleware to an express endpoint. 149 | * var bookResource = new Resource(BookModel); 150 | * // ... 151 | * app.delete('/books/:bookId', bookResource.removeDoc()); 152 | * 153 | * @return {Array} list of express compatible middleware functions. 154 | */ 155 | removeDoc () { 156 | return [ 157 | this.namespace(), 158 | this.read(), 159 | this.execute(), 160 | this.remove(), 161 | this.publish({statusCode: 204, empty: true}) 162 | ]; 163 | } 164 | 165 | /** 166 | * Middleware stack which returns a list of documents from the database. 167 | * It supports advanced filtering, pagination, sorting, field selection, etc. 168 | * 169 | * @example Bind this middleware to an express endpoint. 170 | * var bookResource = new Resource(BookModel); 171 | * // ... 172 | * app.get('/books', bookResource.readDocs()); 173 | * 174 | * @return {Array} list of express compatible middleware functions. 175 | */ 176 | readDocs () { 177 | return [ 178 | this.namespace(), 179 | this.readAll(), 180 | this.pagination(), 181 | this.filters(), 182 | this.sort(), 183 | this.fields(), 184 | this.execute(), 185 | this.publish({statusCode: 200}) 186 | ]; 187 | } 188 | 189 | /** 190 | * Middleware stack which patches all documents selected by the query params. 191 | * This endpoint does not return the modified documents. 192 | * For that you will need to perform another request. 193 | * 194 | * @example Bind this middleware to an express endpoint. 195 | * var bookResource = new Resource(BookModel); 196 | * // ... 197 | * app.patch('/books', bookResource.patchDocs()); 198 | * 199 | * @return {Array} list of express compatible middleware functions. 200 | */ 201 | patchDocs () { 202 | return [ 203 | this.namespace(), 204 | this.readAll(), 205 | this.pagination(), 206 | this.filters(), 207 | this.sort(), 208 | this.patchAll(), 209 | this.publish({statusCode: 200}) 210 | ]; 211 | } 212 | 213 | /** 214 | * Middleware stack which removes all documents matched by the query filters. 215 | * 216 | * @example Bind this middleware to an express endpoint. 217 | * var bookResource = new Resource(BookModel); 218 | * // ... 219 | * app.delete('/books', bookResource.removeDocs()); 220 | * 221 | * @return {Array} list of express compatible middleware functions. 222 | */ 223 | removeDocs () { 224 | return [ 225 | this.namespace(), 226 | this.readAll(), 227 | this.pagination(), 228 | this.filters(), 229 | this.sort(), 230 | this.removeAll(), 231 | this.publish({statusCode: 200, empty: true}) 232 | ]; 233 | } 234 | 235 | /** 236 | * Middleware stack which returns the number of documents in a collection. 237 | * Supports filters. 238 | * 239 | * This endpoint is separate from readDocs because of performance issues, 240 | * mongo does not return a count of all documents when using skip, limit. 241 | * 242 | * @example Bind this middleware to an express endpoint. 243 | * var bookResource = new Resource(BookModel); 244 | * // ... 245 | * app.get('/books/count', bookResource.countDocs()); 246 | * 247 | * @return {Array} list of express compatible middleware functions. 248 | */ 249 | countDocs () { 250 | return [ 251 | this.namespace(), 252 | this.readAll(), 253 | this.filters(), 254 | this.countAll(), 255 | this.publish({statusCode: 200}) 256 | ]; 257 | } 258 | 259 | 260 | // MIDDLEWARE 261 | 262 | /** 263 | * Returns a middleware which sets a namespace object on the http.Request 264 | * instance built by express. It is used to attach data and custom functions. 265 | * 266 | * @return {Function} express compatible middleware function. 267 | */ 268 | namespace () { 269 | return (req, res, next) => { 270 | req[this.ns] = {}; 271 | return next(); 272 | }; 273 | } 274 | 275 | /** 276 | * Middleware start the mongoose.Query object for fetching a 277 | * model from the database. 278 | * 279 | * @param {String} req.params.Id id of model to be returned 280 | * @param {mongoose.Query} req..query the mongoose.Query instance 281 | * that will fetch the data from Mongo. 282 | * @return {Function} middleware function 283 | */ 284 | read () { 285 | return (req, res, next) => { 286 | const id = req.params[this.modelKey]; 287 | if (!id) { 288 | return res.status(404).send({ 289 | msg: 'Document id not provided' 290 | }); 291 | } 292 | 293 | req[this.ns].query = this.Model.findOne() 294 | .where('_id').equals(id); 295 | 296 | return next(); 297 | }; 298 | } 299 | 300 | /** 301 | * Middleware creates a document of the current model type 302 | * from the request json payload. It also publishes the 303 | * newly created document. 304 | * 305 | * Note that this middleware creates the new document from the received 306 | * body without performing validation, this is left to the implemention. 307 | * 308 | * @param {Object} req.body the request payload object to be wrapped. 309 | * @param {mongose.Model} req..result newly created instance of model. 310 | * @return {Function} middleware function 311 | */ 312 | create () { 313 | return (req, res, next) => { 314 | const document = new this.Model(req.body); 315 | document.save((error) => { 316 | if (error) { 317 | this.log('error', 'Failed to store document', error); 318 | return res.status(500).send({ 319 | msg: 'Error storing new document' 320 | }); 321 | } 322 | 323 | req[this.ns].result = document; 324 | return next(); 325 | }); 326 | }; 327 | } 328 | 329 | /** 330 | * Middleware updates one record in the database by it's id. 331 | * If a record isn't found an error is thrown. 332 | * 333 | * @param {Object} req.body the request payload to be added over the 334 | * existing model record. 335 | * @param {mongoose.Model} req..result instance of the Mode to be updated. 336 | * @param {Mixed} req..result instance of the Model that was just updated. 337 | * @return {Function} middleware function 338 | */ 339 | patch () { 340 | return (req, res, next) => { 341 | Object.keys(req.body).forEach((path) => { 342 | const newValue = req.body[path]; 343 | req[this.ns].result.set(path, newValue); 344 | }); 345 | 346 | req[this.ns].result.save((error) => { 347 | if (error) { 348 | this.log('error', 'Failed to patch document', error); 349 | return res.status(500).send({ 350 | msg: 'Error patching document' 351 | }); 352 | } 353 | return next(); 354 | }); 355 | }; 356 | } 357 | 358 | /** 359 | * Middleware replaces a document with the provided body. 360 | * 361 | * Because no better way is available in mongoose, this middleware will 362 | * remove the existing document then insert it again with the new data. 363 | * Please note that this endpoint is not thread safe! 364 | * 365 | * @param {Object} req..result instance of current Model to be replaced. 366 | * @return {Function} middleware function 367 | */ 368 | put () { 369 | return (req, res, next) => { 370 | req[this.ns].result.remove((error) => { 371 | if (error) { 372 | this.log('error', 'Failed to read document for update', error); 373 | return res.status(500).send({ 374 | msg: 'Failed to replace the document' 375 | }); 376 | } 377 | 378 | const document = new this.Model(req.body); 379 | document._id = req[this.ns].result._id; 380 | document.save((error) => { 381 | if (error) { 382 | this.log('error', 'Failed to update document', error); 383 | return res.status(500).send({ 384 | msg: 'Error replacing document' 385 | }); 386 | } 387 | 388 | req[this.ns].result = document; 389 | return next(); 390 | }); 391 | }); 392 | }; 393 | } 394 | 395 | /** 396 | * Middleware removes the specified document from the db if it 397 | * belongs to the current shop. 398 | * 399 | * @param {String} req.params.modelId id of model to be updated 400 | * @param {Object} req..result instance of current Model that was removed 401 | * @return {Function} middleware function 402 | */ 403 | remove () { 404 | return (req, res, next) => { 405 | req[this.ns].result.remove((error) => { 406 | if (error) { 407 | this.log('error', 'Failed to remove document', error); 408 | return res.status(500).send({ 409 | msg: 'error removing document' 410 | }); 411 | } 412 | return next(); 413 | }); 414 | }; 415 | } 416 | /** 417 | * Middleware creates a mongoose.Query instance that fetches all 418 | * models from the database. 419 | * 420 | * @param {mongoose.Query} req..query instance of mongoose.Query to 421 | * fetch models from database. 422 | * @return {Function} middleware function 423 | */ 424 | readAll () { 425 | return (req, res, next) => { 426 | req[this.ns].query = this.Model.find(); 427 | return next(); 428 | }; 429 | } 430 | 431 | /** 432 | * Middleware adds an update clause to query being constructed then executes 433 | * it. This way it updates all documents selected by the query. 434 | * 435 | * @param {Object} req.body payload to overwrite data on selected documents. 436 | * @param {mongoose.Query} req..query instance of mongoose.Query to 437 | * fetch models from database. 438 | * @return {Function} middleware function 439 | */ 440 | patchAll () { 441 | return (req, res, next) => { 442 | req[this.ns].query.setOptions({multi: true}); 443 | req[this.ns].query.update(req.body, (error) => { 444 | if (error) { 445 | this.log('error', 'Failed to update collection', error); 446 | return res.status(500).send({ 447 | msg: 'Unable to patch selected documents' 448 | }); 449 | } 450 | return next(); 451 | }); 452 | }; 453 | } 454 | 455 | /** 456 | * Middleware to remove all documents selected previously. 457 | * 458 | * @param {Number|Object|Array} req..result query result. 459 | * @return {Function} middleware function 460 | */ 461 | removeAll () { 462 | return (req, res, next) => { 463 | req[this.ns].query.remove((error) => { 464 | if (error) { 465 | this.log('error', 'Failed to remove collection', error); 466 | return res.status(500).send({ 467 | msg: 'Failed to remove selected documents' 468 | }); 469 | } 470 | return next(); 471 | }); 472 | }; 473 | } 474 | 475 | updateAll () { 476 | // TODO this middleware should update all documents that support this. 477 | } 478 | 479 | /** 480 | * Middleware counts the number of items currently selected. 481 | * 482 | * @param {Number} req..result the result of the count query. 483 | * @return {Function} middleware function 484 | */ 485 | countAll () { 486 | return (req, res, next) => { 487 | req[this.ns].query.count((error, count) => { 488 | if (error) { 489 | this.log('error', 'Failed to count documents', error); 490 | return res.status(500).send({ 491 | msg: 'Error counting documents' 492 | }); 493 | } 494 | req[this.ns].result = count; 495 | return next(); 496 | }); 497 | }; 498 | } 499 | 500 | /** 501 | * Executes the current built query. It will set the the 502 | * results in req..result or return an error message if 503 | * something goes wrong. 504 | * 505 | * @param {mongoose.Query} req..query query for mongodb. 506 | * @param {Number|Object|Array} req..result query result. 507 | * @return {Function} middleware function 508 | */ 509 | execute () { 510 | return (req, res, next) => { 511 | req[this.ns].query.exec((error, documents) => { 512 | if (error) { 513 | this.log('error', 'Failed to execute mongoose query', error); 514 | return res.status(500).send({ 515 | msg: error.message 516 | }); 517 | } 518 | if (!documents) { 519 | return res.status(404).send({ 520 | msg: 'Resources not found' 521 | }); 522 | } 523 | req[this.ns].result = documents; 524 | return next(); 525 | }); 526 | }; 527 | } 528 | 529 | /** 530 | * Middleware prints the results of a previously executed Query. 531 | * 532 | * @param req..result {Object} results from mongoose.Query 533 | * instance, it's either a Document 534 | * or an array of Documents. 535 | * @param {Object} options 536 | * @param options {Number} statusCode status code returned to the client. 537 | * @param options {Boolean} empty - if the response payload should be empty. 538 | * @return {Function} middleware function 539 | */ 540 | publish (options = {}) { 541 | return (req, res) => { 542 | options.statusCode = options.statusCode || 200; 543 | res.status(options.statusCode); 544 | 545 | options.empty = options.empty || false; 546 | if (options.empty === true) { 547 | return res.status(options.statusCode).send(); 548 | } 549 | 550 | return res.status(options.statusCode).json({ 551 | meta: Object.assign({}, req.query), 552 | data: this.format(req[this.ns].result) 553 | }); 554 | }; 555 | } 556 | 557 | // QUERY MODIFIERS 558 | 559 | /** 560 | * Middleware modifies the current query object to only fetch 561 | * specified fields 562 | * 563 | * @example Have the endpoint return only a subset of the data in docs. 564 | * request.get('/books?_fields=author,title') 565 | * 566 | * @param {String} req.query._fields list of coma separated field keys 567 | * @param {mongoose.Query} req..query fetches the data from Mongo. 568 | * @return {Function} middleware function 569 | */ 570 | fields () { 571 | return (req, res, next) => { 572 | if (!req.query._fields) { 573 | return next(); 574 | } 575 | 576 | const fields = req.query._fields.split(',').join(' '); 577 | req[this.ns].query.select(fields); 578 | return next(); 579 | }; 580 | } 581 | 582 | /** 583 | * Middleware applies pagination parameter to the current query. 584 | * 585 | * @example Have the endpoint return a slice of the collection 586 | * request.get('/books?_skip=100&_limit=200') 587 | * 588 | * @param {Number} req.query._skip where to start fetching the result set. 589 | * @param {Number} req.query._limit how many records to return 590 | * @param {mongoose.Query} req..query fetch models from database. 591 | * @return {Function} middleware function 592 | */ 593 | pagination () { 594 | return (req, res, next) => { 595 | const skip = req.query._skip ? +req.query._skip : 0; 596 | const limit = req.query._limit ? +req.query._limit : this.defaultPageSize; 597 | req[this.ns].query.skip(skip); 598 | req[this.ns].query.limit(limit); 599 | return next(); 600 | }; 601 | } 602 | 603 | /** 604 | * Middleware filters the results of the current query, with 605 | * the params from the request url query string. 606 | * 607 | * It will ignore the reserved query params attached to the class. 608 | * 609 | * This middleware supports multiple operators matching those in mongo: 610 | * eq, ne, lt, lte, gt, gte, regex, etc. 611 | * 612 | * @example How to find all books named Hamlet 613 | * request.get('/books?title__eq=Hamlet') 614 | * 615 | * @example How to find all books with more than 1000 pages 616 | * request.get('/books?numPages__gte=1000') 617 | * 618 | * @example How to find all books written in russian, between 1800 and 1900 619 | * request.get('/books?lang=ru&writtenIn__gte=1800&writtenIn__lte=1900') 620 | * 621 | * @param {Object} req.query query string params acting as filters 622 | * @param {mongoose.Query} req..query fetches models from database. 623 | * @return {Function} middleware function 624 | */ 625 | filters () { 626 | return (req, res, next) => { 627 | Object.keys(req.query).forEach((key) => { 628 | if (this.reservedQueryParams.indexOf(key) !== -1) { 629 | return; 630 | } 631 | 632 | const parts = key.split('__'); 633 | const operand = parts[0]; 634 | let operator = parts[1] || 'eq'; 635 | let value = req.query[key]; 636 | 637 | if (this.supportedQueryOperators.indexOf(operator) === -1) { 638 | return; 639 | } 640 | 641 | switch (operator) { 642 | case 'gt': 643 | value = +value; // cast to int. 644 | break; 645 | case 'gte': 646 | value = +value; // cast to int 647 | break; 648 | case 'lt': 649 | value = +value; // cast to int 650 | break; 651 | case 'lte': 652 | value = +value; // cast to int 653 | break; 654 | case 'in': 655 | value = value.split(','); // transform into array. 656 | break; 657 | case 'regex': 658 | value = new RegExp(value, 'gim'); 659 | break; 660 | } 661 | req[this.ns].query.where(operand)[operator](value); 662 | }); 663 | 664 | return next(); 665 | }; 666 | } 667 | 668 | /** 669 | * Middleware sorts the result set by the given _sort field. 670 | * 671 | * The _sort value is a field name of the current model by 672 | * which the sort will be performed. 673 | * 674 | * In addition the field name can be prefixed with `-` to 675 | * indicate sorting in descending order. 676 | * 677 | * @example Retrieve all books sorted by title descending. 678 | * request.get('/books?_sort=-title') 679 | * 680 | * @param {Object} req.query dict with url query string params. 681 | * @param {String} req.query._sort the field/order to sort by. 682 | * Eg `&_sort=-createdOn` 683 | * @param {Object} req..query instance of mongoose.Query to 684 | * fetch models from database. 685 | * @return {Function} middleware function 686 | */ 687 | sort () { 688 | return (req, res, next) => { 689 | if (this.is('String', req.query._sort)) { 690 | req[this.ns].query.sort(req.query._sort); 691 | } 692 | return next(); 693 | }; 694 | } 695 | 696 | /** 697 | * Format the database results. By default, mongoose serializes the 698 | * documents, but you should override this method for custom formatting. 699 | * 700 | * @param {Object} results depend on the resolved query. 701 | * 702 | * @return {Function} middleware function 703 | */ 704 | format (results) { 705 | return results; 706 | } 707 | 708 | /** 709 | * Helper function to check if a value is of a given type. 710 | * @param {String} type - one of Array, Function, String, Number, Boolean... 711 | * @param {Object} value - any javascript value. 712 | * 713 | * @return {Boolean} 714 | */ 715 | is (type, value) { 716 | return Object.prototype.toString.call(value) === `[object ${type}]`; 717 | } 718 | 719 | /** 720 | * Determins whether the passed in constructor function is a mongoose.Model. 721 | * @param {Function} model 722 | * 723 | * @return {Boolean} 724 | */ 725 | isMongooseModel (model ) { 726 | return this.is('Function', model) && 727 | this.is('String', model.modelName) && 728 | this.is('Function', model.findOne) && 729 | this.is('Function', model.find); 730 | } 731 | } 732 | 733 | // Public API. 734 | module.exports = Resource; 735 | -------------------------------------------------------------------------------- /test/Resource.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | 3 | const bodyParser = require('body-parser'); 4 | const chai = require('chai'); 5 | const express = require('express'); 6 | const mongoose = require('mongoose'); 7 | const request = require('supertest'); 8 | 9 | const fixture = require('./fixture'); 10 | const mortimer = require('../'); 11 | 12 | const Book = fixture.Book; 13 | 14 | const serialize = (mongooseObject) => { 15 | return JSON.parse(JSON.stringify(mongooseObject)); 16 | }; 17 | 18 | mongoose.Promise = global.Promise; 19 | 20 | describe('Resource', () => { 21 | before(() => { 22 | this.rest = new mortimer.Resource(Book); 23 | 24 | this.app = express(); 25 | this.app.set('query parser', 'simple'); 26 | this.app.use(bodyParser.json()); 27 | 28 | this.app.get('/books/count', this.rest.countDocs()); 29 | this.app.get('/books/:bookId', this.rest.readDoc()); 30 | this.app.patch('/books/:bookId', this.rest.patchDoc()); 31 | this.app.put('/books/:bookId', this.rest.putDoc()); 32 | this.app.delete('/books/:bookId', this.rest.removeDoc()); 33 | this.app.get('/books', this.rest.readDocs()); 34 | this.app.patch('/books', this.rest.patchDocs()); 35 | this.app.post('/books', this.rest.createDoc()); 36 | this.app.delete('/books', this.rest.removeDocs()); 37 | 38 | this.server = http.createServer(this.app); 39 | }); 40 | 41 | beforeEach((done) => { 42 | this.book1 = new Book({ 43 | title: 'book1', 44 | author: 'author1', 45 | details: { 46 | numPages: 300 47 | } 48 | }); 49 | this.book2 = new Book({ 50 | title: 'book2', 51 | author: 'author1', 52 | details: { 53 | numPages: 400 54 | } 55 | }); 56 | Promise.all([ 57 | this.book1.save(), 58 | this.book2.save(), 59 | ]).then(() => done(), done); 60 | }); 61 | 62 | afterEach((done) => { 63 | Book.collection.remove().then(() => done(), done); 64 | }); 65 | 66 | describe('.constructor()', () => { 67 | it('should throw an error when an invalid model is passed in', () => { 68 | chai.assert.throws(() => { 69 | new mortimer.Resource(Array); 70 | }, Error, 'Resource expected an instance of mongoose.Model'); 71 | }); 72 | }); 73 | 74 | describe('.createDoc()', () => { 75 | it('should create a new book record', (done) => { 76 | const payload = { 77 | title: 'book3', 78 | author: 'author1' 79 | }; 80 | 81 | // Call the http endpoint. 82 | 83 | new Promise((resolve, reject) => { 84 | request(this.server) 85 | .post('/books') 86 | .set('Content-Type', 'application/json') 87 | .set('Accept', 'application/json') 88 | .send(payload) 89 | .end((err, res) => { 90 | if (err) return reject(err); 91 | return resolve(res); 92 | }); 93 | }).then((res) => { 94 | chai.assert.equal(res.statusCode, 201, 95 | 'should succesfully create a new book'); 96 | chai.assert.isDefined(res.body.data._id, 97 | 'should return the id'); 98 | chai.assert.equal(res.body.data.title, payload.title, 99 | 'should return the books title'); 100 | chai.assert.equal(res.body.data.author, payload.author, 101 | 'should return the books author'); 102 | 103 | // Check in the database. 104 | return Book.findOne() 105 | .where('title').equals(payload.title) 106 | .where('author').equals(payload.author) 107 | .exec(); 108 | }).then((newBook) => { 109 | chai.assert.isNotNull(newBook, 110 | 'should have stored the new book'); 111 | }).then(()=> done(), done); 112 | }); 113 | 114 | it('should not add a new book because of bad input data', (done) => { 115 | const payload = { 116 | name: 'book3', 117 | writer: 'author1' 118 | }; 119 | 120 | // Call the http endpoint. 121 | new Promise((resolve, reject) => { 122 | request(this.server) 123 | .post('/books') 124 | .set('Content-Type', 'application/json') 125 | .set('Accept', 'application/json') 126 | .send(payload) 127 | .end((err, res) => { 128 | if (err) return reject(err); 129 | return resolve(res); 130 | }); 131 | }).then((res) => { 132 | chai.assert.equal(res.statusCode, 500, 'bad input data'); 133 | // Check in the database. 134 | return Book.count().exec(); 135 | }).then((numBooks) => { 136 | chai.assert.equal(numBooks, 2, 137 | 'should have found only the original 2 books'); 138 | }).then(() => done(), done); 139 | }); 140 | }); 141 | 142 | describe('.readDoc()', () => { 143 | it('should return an existing book record', (done) => { 144 | new Promise((resolve, reject) => { 145 | request(this.server) 146 | .get(`/books/${this.book1._id}`) 147 | .set('Accept', 'application/json') 148 | .end((err, res) => { 149 | if (err) reject(err); 150 | else resolve(res); 151 | }); 152 | }).then((res) => { 153 | chai.assert.equal(res.statusCode, 200, 'Ok'); 154 | chai.assert.equal(res.body.data.title, this.book1.title, 155 | 'correct title'); 156 | chai.assert.equal(res.body.data.author, this.book1.author, 157 | 'correct author'); 158 | }).then(() => done(), done); 159 | }); 160 | 161 | it('should return 404 not found when id does not exist', (done) => { 162 | new Promise((resolve, reject) => { 163 | request(this.server) 164 | .get('/books/123456781234567812345678') 165 | .set('Accept', 'application/json') 166 | .end((err, res) => { 167 | if (err) reject(err); 168 | else resolve(res); 169 | }); 170 | }).then((res) => { 171 | chai.assert.equal(res.statusCode, 404, 'Ok'); 172 | }).then(() => done(), done); 173 | }); 174 | 175 | describe('.fields() modifier', () => { 176 | it('should only return fields of interest', (done) => { 177 | new Promise((resolve, reject) => { 178 | request(this.server) 179 | .get(`/books/${this.book1._id}?_fields=title`) 180 | .set('Accept', 'application/json') 181 | .end((err, res) => { 182 | if (err) reject(err); 183 | else resolve(res); 184 | }); 185 | }).then((res) => { 186 | chai.assert.equal(res.statusCode, 200, 'Ok'); 187 | chai.assert.deepEqual(res.body.meta, {'_fields': 'title'}, 188 | 'should return back the query params'); 189 | chai.assert.equal(res.body.data.title, this.book1.title, 190 | 'correct title'); 191 | chai.assert.isUndefined(res.body.data.author, 192 | 'does not return author because it was not requested'); 193 | }).then(() => done(), done); 194 | }); 195 | }); 196 | }); 197 | 198 | describe('.patchDoc()', () => { 199 | it('should patch an existing book record', (done) => { 200 | const payload = { 201 | title: 'book3' 202 | }; 203 | new Promise((resolve, reject) => { 204 | request(this.server) 205 | .patch(`/books/${this.book1._id}`) 206 | .set('Content-Type', 'application/json') 207 | .set('Accept', 'application/json') 208 | .send(payload) 209 | .end((err, res) => { 210 | if (err) reject(err); 211 | else resolve(res); 212 | }); 213 | }).then((res) => { 214 | chai.assert.equal(res.statusCode, 200, 'update should work ok'); 215 | chai.assert.equal(res.body.data.title, payload.title, 216 | 'should return the new book title'); 217 | chai.assert.equal(res.body.data.author, this.book1.author, 218 | 'should return the old book author'); 219 | 220 | // Check in the database. 221 | return Book.findOne() 222 | .where('_id').equals(this.book1._id) 223 | .exec(); 224 | }).then((updatedBook) => { 225 | chai.assert.equal(updatedBook.title, payload.title, 226 | 'should have persisted the changes'); 227 | chai.assert.equal(updatedBook.author, this.book1.author, 228 | 'book document should not be changed'); 229 | }).then(() => done(), done); 230 | }); 231 | 232 | it('should return 500 for a failed update', (done) => { 233 | const payload = { 234 | title: null // `title` is expected to be a string. 235 | }; 236 | new Promise((resolve, reject) => { 237 | request(this.server) 238 | .patch(`/books/${this.book1._id}`) 239 | .set('Content-Type', 'application/json') 240 | .set('Accept', 'application/json') 241 | .send(payload) 242 | .end((err, res) => { 243 | if (err) reject(err); 244 | else resolve(res); 245 | }); 246 | }).then((res) => { 247 | chai.assert.equal(res.statusCode, 500, 248 | 'should return an error'); 249 | 250 | // Check in the database. 251 | return Book.findOne() 252 | .where('_id').equals(this.book1._id) 253 | .exec(); 254 | }).then((book) => { 255 | chai.assert.equal(book.title, this.book1.title, 256 | 'should have remained to the old version of title'); 257 | }).then(() => done(), done); 258 | }); 259 | }); 260 | 261 | describe('.putDoc()', () => { 262 | it('should replace existing resource with request body', (done) => { 263 | const payload = { 264 | title: 'book11', 265 | author: 'author11' 266 | }; 267 | new Promise((resolve, reject) => { 268 | request(this.server) 269 | .put(`/books/${this.book1._id}`) 270 | .set('Content-Type', 'application/json') 271 | .set('Accept', 'application/json') 272 | .send(payload) 273 | .end((err, res) => { 274 | if (err) reject(err); 275 | else resolve(res); 276 | }); 277 | }).then((res) => { 278 | chai.assert.equal(res.statusCode, 200, 'should return an error'); 279 | chai.assert.equal(res.body.data.title, payload.title); 280 | chai.assert.equal(res.body.data.author, payload.author); 281 | chai.assert.isUndefined(res.body.data.details, 282 | 'should no longer have details'); 283 | 284 | // Check in the database. 285 | return Book.findOne() 286 | .where('_id').equals(this.book1._id) 287 | .exec(); 288 | }).then((book) => { 289 | chai.assert.equal(book.title, payload.title); 290 | chai.assert.equal(book.author, payload.author); 291 | chai.assert.isUndefined(book.details, 292 | 'should no longer have details'); 293 | }).then(() => done(), done); 294 | }); 295 | }); 296 | 297 | describe('.removeDoc()', () => { 298 | it('should remove an existing book record', (done) => { 299 | new Promise((resolve, reject) => { 300 | request(this.server) 301 | .del(`/books/${this.book1._id}`) 302 | .end((err, res) => { 303 | if (err) reject(err); 304 | else resolve(res); 305 | }); 306 | }).then((res) => { 307 | chai.assert.equal(res.statusCode, 204, 308 | 'delete should have worked'); 309 | chai.assert.deepEqual(res.body, {}, 310 | 'delete should return empty payload'); 311 | 312 | return new Promise((resolve, reject) => { 313 | request(this.server) 314 | .get(`/books/${this.book1._id}`) 315 | .set('Accept', 'application/json') 316 | .end((err, res) => { 317 | if (err) reject(err); 318 | else resolve(res); 319 | }); 320 | }); 321 | }).then((res) => { 322 | chai.assert.equal(res.statusCode, 404, 323 | 'should not find any book'); 324 | 325 | // Check in the database. 326 | return Book.findOne() 327 | .where('_id').equals(this.book1._id) 328 | .exec(); 329 | }).then((book) => { 330 | chai.assert.isNull(book, 'should not find the book'); 331 | }).then(() => done(), done); 332 | }); 333 | }); 334 | 335 | describe('.readDocs()', () => { 336 | it('should return all existing book records', (done) => { 337 | new Promise((resolve, reject) => { 338 | request(this.server) 339 | .get('/books') 340 | .set('Accept', 'application/json') 341 | .end((err, res) => { 342 | if (err) reject(err); 343 | else resolve(res); 344 | }); 345 | }).then((res) => { 346 | chai.assert.equal(res.statusCode, 200, 'update should work ok'); 347 | chai.assert.deepEqual(res.body.meta, {}, 'no meta data is returned'); 348 | chai.assert.lengthOf(res.body.data, 2, 'should return two objects'); 349 | chai.assert.deepEqual(res.body.data[0], serialize(this.book1), 350 | 'should return the first book'); 351 | chai.assert.deepEqual(res.body.data[1], serialize(this.book2), 352 | 'should return the second book'); 353 | }).then(() => done(), done); 354 | }); 355 | 356 | describe('.pagination() modifier', () => { 357 | 358 | it('should be able to paginate results', (done) => { 359 | new Promise((resolve, reject) => { 360 | request(this.server) 361 | .get('/books?_skip=1&_limit=1') 362 | .set('Accept', 'application/json') 363 | .end((err, res) => { 364 | if (err) reject(err); 365 | else resolve(res); 366 | }); 367 | }).then((res) => { 368 | chai.assert.equal(res.statusCode, 200, 369 | 'should return the correct value'); 370 | chai.assert.deepEqual(res.body.meta, {'_skip': '1', '_limit': '1'}, 371 | 'should return the metadata from the request'); 372 | chai.assert.lengthOf(res.body.data, 1, 373 | 'should return only the second book'); 374 | chai.assert.deepEqual(res.body.data[0], serialize(this.book2), 375 | 'should return the books contents'); 376 | }).then(() => done(), done); 377 | }); 378 | 379 | it('should fallback to default page size for documents', (done) => { 380 | // Checks if default value of pagination 381 | // offset and limit is respected, ie. it doesn't return 382 | // the entire collection on a GET. 383 | const insertedBooks = [...Array(20).keys()].map((index) => { 384 | // Insert a book and returns a promise so we can chain it. 385 | return new Book({ 386 | title: `book${index}`, 387 | author: `author${index}` 388 | }).save(); 389 | }); 390 | 391 | Promise.all(insertedBooks).then(() => { 392 | return new Promise((resolve, reject) => { 393 | request(this.server) 394 | .get('/books') 395 | .set('Accept', 'application/json') 396 | .end((err, res) => { 397 | if (err) reject(err); 398 | else resolve(res); 399 | }); 400 | }).then((res) => { 401 | chai.assert.equal(res.statusCode, 200, 402 | 'should return the correct value'); 403 | chai.assert.lengthOf(res.body.data, 10, 404 | 'should return only the first page of documents'); 405 | const expectedTitles = [...Array(10).keys()].map((i) => `book${i}`); 406 | const actualTitles = res.body.data.map((b) => b.title); 407 | chai.assert.includeMembers(expectedTitles, actualTitles, 408 | 'should fetch only the first inserted books'); 409 | }).then(() => done(), done); 410 | }); 411 | }); 412 | }); 413 | 414 | describe('.filters() modifier', () => { 415 | 416 | it('__eq filters by field value', (done) => { 417 | new Promise((resolve, reject) => { 418 | request(this.server) 419 | .get('/books?title__eq=book1') 420 | .set('Accept', 'application/json') 421 | .end((err, res) => { 422 | if (err) reject(err); 423 | else resolve(res); 424 | }); 425 | }).then((res) => { 426 | chai.assert.equal(res.statusCode, 200, 427 | 'should return the correct value'); 428 | chai.assert.deepEqual(res.body.meta, {'title__eq': 'book1'}, 429 | 'should return back the fitler'); 430 | chai.assert.lengthOf(res.body.data, 1, 431 | 'should return only the first book'); 432 | chai.assert.deepEqual(res.body.data[0], serialize(this.book1), 433 | 'should return the first book'); 434 | }).then(() => done(), done); 435 | }); 436 | 437 | it('__eq is default filter when non is specified', (done) => { 438 | new Promise((resolve, reject) => { 439 | request(this.server) 440 | .get('/books?title=book1') 441 | .set('Accept', 'application/json') 442 | .end((err, res) => { 443 | if (err) reject(err); 444 | else resolve(res); 445 | }); 446 | }).then((res) => { 447 | chai.assert.equal(res.statusCode, 200, 448 | 'should return the correct value'); 449 | chai.assert.deepEqual(res.body.meta, {'title': 'book1'}, 450 | 'should return back the fitler'); 451 | chai.assert.lengthOf(res.body.data, 1, 452 | 'should return only the first book'); 453 | chai.assert.deepEqual(res.body.data[0], serialize(this.book1), 454 | 'should return the first book'); 455 | }).then(() => done(), done); 456 | }); 457 | 458 | it('__ne filter acts as not equal to value', (done) => { 459 | new Promise((resolve, reject) => { 460 | request(this.server) 461 | .get('/books?title__ne=book1') 462 | .set('Accept', 'application/json') 463 | .end((err, res) => { 464 | if (err) reject(err); 465 | else resolve(res); 466 | }); 467 | }).then((res) => { 468 | chai.assert.equal(res.statusCode, 200, 469 | 'should return the correct value'); 470 | chai.assert.deepEqual(res.body.meta, {'title__ne': 'book1'}, 471 | 'should return back the fitler'); 472 | chai.assert.lengthOf(res.body.data, 1, 473 | 'should return only the first book'); 474 | chai.assert.deepEqual(res.body.data[0], serialize(this.book2), 475 | 'should return the first book'); 476 | }).then(() => done(), done); 477 | }); 478 | 479 | it('__gt filter acts as strictly greater than', (done) => { 480 | new Promise((resolve, reject) => { 481 | request(this.server) 482 | .get('/books?details.numPages__gt=350') 483 | .set('Accept', 'application/json') 484 | .end((err, res) => { 485 | if (err) reject(err); 486 | else resolve(res); 487 | }); 488 | }).then((res) => { 489 | chai.assert.equal(res.statusCode, 200, 490 | 'should return the correct value'); 491 | chai.assert.deepEqual(res.body.meta, {'details.numPages__gt': '350'}, 492 | 'should return back the fitler'); 493 | chai.assert.lengthOf(res.body.data, 1, 494 | 'should return only the first book'); 495 | chai.assert.deepEqual(res.body.data[0], serialize(this.book2), 496 | 'should return the first book'); 497 | }).then(() => done(), done); 498 | }); 499 | 500 | it('__gte filter acts as greater than or equal to', (done) => { 501 | new Promise((resolve, reject) => { 502 | request(this.server) 503 | .get('/books?details.numPages__gte=400') 504 | .set('Accept', 'application/json') 505 | .end((err, res) => { 506 | if (err) reject(err); 507 | else resolve(res); 508 | }); 509 | }).then((res) => { 510 | chai.assert.equal(res.statusCode, 200, 511 | 'should return the correct value'); 512 | chai.assert.deepEqual(res.body.meta, {'details.numPages__gte': '400'}, 513 | 'should return back the fitler'); 514 | chai.assert.lengthOf(res.body.data, 1, 515 | 'should return only the first book'); 516 | chai.assert.deepEqual(res.body.data[0], serialize(this.book2), 517 | 'should return the first book'); 518 | }).then(() => done(), done); 519 | }); 520 | 521 | it('__lt filter acts as strictly less than', (done) => { 522 | new Promise((resolve, reject) => { 523 | request(this.server) 524 | .get('/books?details.numPages__lt=350') 525 | .set('Accept', 'application/json') 526 | .end((err, res) => { 527 | if (err) reject(err); 528 | else resolve(res); 529 | }); 530 | }).then((res) => { 531 | chai.assert.equal(res.statusCode, 200, 532 | 'should return the correct value'); 533 | chai.assert.deepEqual(res.body.meta, {'details.numPages__lt': '350'}, 534 | 'should return back the fitler'); 535 | chai.assert.lengthOf(res.body.data, 1, 536 | 'should return only the first book'); 537 | chai.assert.deepEqual(res.body.data[0], serialize(this.book1), 538 | 'should return the first book'); 539 | }).then(() => done(), done); 540 | }); 541 | 542 | it('__lte filter acts as less than or equal to', (done) => { 543 | new Promise((resolve, reject) => { 544 | request(this.server) 545 | .get('/books?details.numPages__lte=300') 546 | .set('Accept', 'application/json') 547 | .end((err, res) => { 548 | if (err) reject(err); 549 | else resolve(res); 550 | }); 551 | }).then((res) => { 552 | chai.assert.equal(res.statusCode, 200, 553 | 'should return the correct value'); 554 | chai.assert.deepEqual(res.body.meta, {'details.numPages__lte': '300'}, 555 | 'should return back the fitler'); 556 | chai.assert.lengthOf(res.body.data, 1, 557 | 'should return only the first book'); 558 | chai.assert.deepEqual(res.body.data[0], serialize(this.book1), 559 | 'should return the first book'); 560 | }).then(() => done(), done); 561 | }); 562 | 563 | it('__in should be able to return documents with value within a list', (done) => { 564 | new Promise((resolve, reject) => { 565 | request(this.server) 566 | .get(`/books?_id__in=${this.book1._id},${this.book2._id}`) 567 | .set('Accept', 'application/json') 568 | .end((err, res) => { 569 | if (err) reject(err); 570 | else resolve(res); 571 | }); 572 | }).then((res) => { 573 | chai.assert.equal(res.statusCode, 200, 'update should work ok'); 574 | chai.assert.deepEqual(res.body.meta, 575 | {_id__in: `${this.book1._id},${this.book2._id}`}, 576 | 'added metadata to the response'); 577 | chai.assert.lengthOf(res.body.data, 2, 'should return two objects'); 578 | chai.assert.deepEqual(res.body.data[0], serialize(this.book1), 579 | 'should return the first book'); 580 | chai.assert.deepEqual(res.body.data[1], serialize(this.book2), 581 | 'should return the second book'); 582 | }).then(() => done(), done); 583 | }); 584 | 585 | it('__regex should support filtering by regular expr', (done) => { 586 | new Promise((resolve, reject) => { 587 | request(this.server) 588 | .get('/books?title__regex=k1') 589 | .set('Accept', 'application/json') 590 | .end((err, res) => { 591 | if (err) reject(err); 592 | else resolve(res); 593 | }); 594 | }).then((res) => { 595 | chai.assert.equal(res.statusCode, 200, 596 | 'should return the correct value'); 597 | chai.assert.deepEqual(res.body.meta, 598 | {'title__regex': 'k1'}, 599 | 'added metadata to the response'); 600 | chai.assert.lengthOf(res.body.data, 1, 601 | 'returns only the first book which has one in title'); 602 | chai.assert.deepEqual(res.body.data[0], serialize(this.book1), 603 | 'should return the correct book'); 604 | }).then(() => done(), done); 605 | }); 606 | 607 | it('should ignore operators that are not predefined', (done) => { 608 | new Promise((resolve, reject) => { 609 | request(this.server) 610 | .get('/books?title__someoperator=k1') 611 | .set('Accept', 'application/json') 612 | .end((err, res) => { 613 | if (err) reject(err); 614 | else resolve(res); 615 | }); 616 | }).then((res) => { 617 | chai.assert.equal(res.statusCode, 200, 618 | 'should return the correct value'); 619 | chai.assert.deepEqual(res.body.meta, 620 | {'title__someoperator': 'k1'}, 621 | 'added metadata to the response'); 622 | chai.assert.lengthOf(res.body.data, 2, 'returns all the books'); 623 | }).then(() => done(), done); 624 | }); 625 | }); 626 | 627 | describe('.sort() modifier', () => { 628 | 629 | it('should order results descending by the given param', (done) => { 630 | new Promise((resolve, reject) => { 631 | request(this.server) 632 | .get('/books?_sort=-title') 633 | .set('Accept', 'application/json') 634 | .end((err, res) => { 635 | if (err) reject(err); 636 | else resolve(res); 637 | }); 638 | }).then((res) => { 639 | chai.assert.equal(res.statusCode, 200, 640 | 'should return the correct value'); 641 | chai.assert.deepEqual(res.body.meta, 642 | {'_sort': '-title'}, 643 | 'added metadata to the response'); 644 | chai.assert.lengthOf(res.body.data, 2, 645 | 'should return both book sorted by title'); 646 | chai.assert.deepEqual(res.body.data[0], serialize(this.book2), 647 | 'second book returnd is the first one'); 648 | chai.assert.deepEqual(res.body.data[1], serialize(this.book1), 649 | 'first book returned is the second one'); 650 | }).then(() => done(), done); 651 | }); 652 | }); 653 | 654 | describe('.fields() modifier', () => { 655 | it('should be able to select only specific fields', (done) => { 656 | new Promise((resolve, reject) => { 657 | request(this.server) 658 | .get('/books?_fields=title') 659 | .set('Accept', 'application/json') 660 | .end((err, res) => { 661 | if (err) reject(err); 662 | else resolve(res); 663 | }); 664 | }).then((res) => { 665 | chai.assert.equal(res.statusCode, 200, 666 | 'should return the correct value'); 667 | chai.assert.lengthOf(res.body.data, 2, 668 | 'should return both book records'); 669 | chai.assert.equal(res.body.data[0].title, this.book1.title, 670 | 'title field should be populated'); 671 | chai.assert.isUndefined(res.body.data[0].author, 672 | 'author field should not be populated'); 673 | chai.assert.equal(res.body.data[1].title, this.book2.title, 674 | 'title fields should be populated'); 675 | chai.assert.isUndefined(res.body.data[1].author, 676 | 'author field should not be populated'); 677 | }).then(() => done(), done); 678 | }); 679 | }); 680 | }); 681 | 682 | describe('.removeDocs()', () => { 683 | 684 | describe('.filter() modifier', () => { 685 | 686 | it('should remove the documents selected by the query', (done) => { 687 | new Promise((resolve, reject) => { 688 | request(this.server) 689 | .delete('/books?title__regex=1') 690 | .set('Accept', 'application/json') 691 | .end((err, res) => { 692 | if (err) reject(err); 693 | else resolve(res); 694 | }); 695 | }).then((res) => { 696 | chai.assert.equal(res.statusCode, 200, 'should have worked ok'); 697 | 698 | // Check the database. 699 | return Book.find().exec(); 700 | }).then((books) => { 701 | chai.assert.lengthOf(books, 1, 702 | 'should have removed the matching documents'); 703 | chai.assert.equal(books[0]._id.toString(), this.book2._id.toString(), 704 | 'should have kept the book that does not match the filters'); 705 | }).then(() => done(), done); 706 | }); 707 | }); 708 | }); 709 | 710 | describe('.patchDocs()', () => { 711 | 712 | describe('.filter() modifier', () => { 713 | 714 | it('should update all selected books', (done) => { 715 | const payload = { 716 | author: 'author11' 717 | }; 718 | new Promise((resolve, reject) => { 719 | request(this.server) 720 | .patch('/books?author__eq=author1') 721 | .set('Content-Type', 'application/json') 722 | .set('Accept', 'application/json') 723 | .send(payload) 724 | .end((err, res) => { 725 | if (err) reject(err); 726 | else resolve(res); 727 | }); 728 | }).then((res) => { 729 | chai.assert.equal(res.statusCode, 200, 'should have worked ok'); 730 | chai.assert.deepEqual(res.body.meta, 731 | {'author__eq': 'author1'}, 732 | 'added metadata to the response'); 733 | 734 | // Check the database. 735 | return Book.find().exec(); 736 | }).then((books) => { 737 | chai.assert.lengthOf(books, 2, 738 | 'should have the same number of books'); 739 | chai.assert.equal(books[0].author, payload.author, 740 | 'should have updated the first book'); 741 | chai.assert.equal(books[1].author, payload.author, 742 | 'should have updated the second book'); 743 | }).then(() => done(), done); 744 | }); 745 | }); 746 | }); 747 | 748 | describe('.countDocs()', () => { 749 | 750 | it('should return a count of all books', (done) => { 751 | new Promise((resolve, reject) => { 752 | request(this.server) 753 | .get('/books/count') 754 | .set('Accept', 'application/json') 755 | .end((err, res) => { 756 | if (err) reject(err); 757 | else resolve(res); 758 | }); 759 | }).then((res) => { 760 | chai.assert.equal(res.statusCode, 200, 'update should work ok'); 761 | chai.assert.deepEqual(res.body.meta, {}, 762 | 'no filters were used'); 763 | chai.assert.equal(res.body.data, 2, 764 | 'should count all the books in the database'); 765 | }).then(() => done(), done); 766 | }); 767 | 768 | describe('.filter() modifier', () => { 769 | 770 | it('should return the book count given a filter', (done) => { 771 | new Promise((resolve, reject) => { 772 | request(this.server) 773 | .get('/books/count?title=book1') 774 | .set('Accept', 'application/json') 775 | .end((err, res) => { 776 | if (err) reject(err); 777 | else resolve(res); 778 | }); 779 | }).then((res) => { 780 | chai.assert.equal(res.statusCode, 200, 'update should work ok'); 781 | chai.assert.deepEqual(res.body.meta, {'title': 'book1'}, 782 | 'should return the filters used'); 783 | chai.assert.equal(res.body.data, 1, 784 | 'should count only one book for that filter'); 785 | }).then(() => done(), done); 786 | }); 787 | }); 788 | }); 789 | }); 790 | --------------------------------------------------------------------------------