├── .travis.yml ├── .gitignore ├── example.js ├── model.json ├── package.json ├── LICENSE ├── index.js ├── readme.md └── test.js /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'stable' 4 | - '5' 5 | - '4' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | npm-debug.log* 4 | .nyc_output 5 | package-lock.json -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | var rest = require('.') 2 | var merry = require('merry') 3 | var memdb = require('memdb') 4 | 5 | var app = merry() 6 | var api = rest(app, { default: function (req, res, ctx) { ctx.send(404, { message: 'not found' }) } }) 7 | 8 | // db is a levelup instance 9 | var model = api.model(memdb(), 'model.json') 10 | 11 | api.resource(model, 'model') 12 | api.start() 13 | -------------------------------------------------------------------------------- /model.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "id": { 5 | "type": "number" 6 | }, 7 | "name": { 8 | "type": "string" 9 | }, 10 | "mail": { 11 | "type": "string", 12 | "format": "email" 13 | }, 14 | "games": { 15 | "type": "array", 16 | "items": {"type": "number"}, 17 | "uniqueItems": true 18 | } 19 | }, 20 | "required": ["mail", "name"] 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "keywords": [ 3 | "merry", 4 | "rest", 5 | "json", 6 | "json-schema", 7 | "server" 8 | ], 9 | "license": "MIT", 10 | "name": "merry-rest", 11 | "description": "serve rest apis with merry and json-schema", 12 | "author": "YerkoPalma", 13 | "version": "1.0.1", 14 | "main": "index.js", 15 | "files": [ 16 | "index.js" 17 | ], 18 | "scripts": { 19 | "start": "node example.js", 20 | "test": "standard --verbose | snazzy && tape test.js | tap-summary" 21 | }, 22 | "repository": "YerkoPalma/merry-rest", 23 | "devDependencies": { 24 | "got": "^7.1.0", 25 | "memdb": "^1.3.1", 26 | "merry": "^5.3.3", 27 | "snazzy": "^7.0.0", 28 | "standard": "^10.0.2", 29 | "tap-summary": "^3.0.2", 30 | "tape": "^4.8.0" 31 | }, 32 | "dependencies": { 33 | "level-rest-parser": "^2.0.0", 34 | "rest-parser": "^1.0.6" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Yerko Palma 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 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var LevelRest = require('level-rest-parser') 2 | var RestParser = require('rest-parser') 3 | var http = require('http') 4 | var path = require('path') 5 | var assert = require('assert') 6 | 7 | module.exports = Nanoapp 8 | 9 | function Nanoapp (api, opt) { 10 | if (!(this instanceof Nanoapp)) return new Nanoapp(api, opt) 11 | opt = opt || {} 12 | 13 | assert.ok(api, 'Nanoapp: api must be defined') 14 | assert.equal(typeof opt, 'object', 'Nanoapp: opt must be an object') 15 | 16 | this.api = api 17 | this.prefix = '/api/v' + (opt.version || '1') 18 | opt.default 19 | ? this.api.route('default', opt.default) 20 | : this.api.route('default', function (req, res, ctx) { ctx.send(404, {}) }) 21 | } 22 | 23 | Nanoapp.prototype.model = function (db, schema) { 24 | assert.ok(db, 'Nanoapp: db must be defined') 25 | assert.equal(typeof schema, 'string', 'Nanoapp: schema must be a string') 26 | 27 | var model = new RestParser(LevelRest(db, { 28 | schema: require(path.join(process.cwd(), schema)) 29 | })) 30 | return model 31 | } 32 | 33 | Nanoapp.prototype.resource = function (model, opt) { 34 | assert.ok(typeof opt === 'object' || typeof opt === 'string', 'Nanoapp: opt must be an object or a string') 35 | assert.ok(!opt.only || !opt.except, 'Nanoapp: can not define `only` and `except` options at the same time') 36 | 37 | var route = '/' + (typeof opt === 'string' ? opt : opt.route) 38 | assert.equal(typeof route, 'string', 'Nanoapp: route not defined') 39 | 40 | var noIdMethods = ['GET', 'POST'] 41 | var idMethods = ['GET', 'PUT', 'DELETE'] 42 | if (opt.only) { 43 | noIdMethods = noIdMethods.filter(function (method) { 44 | return opt.only.indexOf(method) > -1 45 | }) 46 | idMethods = idMethods.filter(function (method) { 47 | return opt.only.indexOf(method) > -1 48 | }) 49 | } 50 | if (opt.except) { 51 | noIdMethods = noIdMethods.filter(function (method) { 52 | return opt.except.indexOf(method) < 0 53 | }) 54 | idMethods = idMethods.filter(function (method) { 55 | return opt.except.indexOf(method) < 0 56 | }) 57 | } 58 | noIdMethods.length > 0 && this.api.route(noIdMethods, this.prefix + route, dispatch(model, opt)) 59 | idMethods.length > 0 && this.api.route(idMethods, this.prefix + route + '/:id', dispatch(model, opt)) 60 | } 61 | 62 | Nanoapp.prototype.route = function (method, route, handler) { 63 | assert.equal(typeof method, 'string') 64 | assert.equal(typeof route, 'string') 65 | assert.equal(typeof handler, 'function') 66 | 67 | this.api.route(method, route, handler) 68 | } 69 | 70 | Nanoapp.prototype.start = function (cb) { 71 | cb = cb || function () {} 72 | assert.equal(typeof cb, 'function', 'Nanoapp: cb must be a function') 73 | 74 | var handler = this.api.start() 75 | var server = http.createServer(handler) 76 | server.listen(process.env.PORT || 8080, process.env.IP || 'localhost', cb) 77 | return server 78 | } 79 | 80 | function dispatch (model, opt) { 81 | return function (req, res, ctx) { 82 | if (opt.before) { 83 | assert.equal(typeof opt.before, 'function', 'Nanoapp: before hook must be a function') 84 | opt.before(req, res, ctx, next) 85 | } else { 86 | next(req, res, ctx) 87 | } 88 | function next (req, res, ctx) { 89 | if (opt.after) assert.equal(typeof opt.after, 'function', 'Nanoapp: after hook must be a function') 90 | 91 | model.dispatch(req, Object.assign({ valueEncoding: 'json' }, ctx.params), function (err, data) { 92 | if (err) { 93 | if (err.notFound) { 94 | ctx.send(404, { message: 'resource not found' }) 95 | } else { 96 | ctx.send(500, { message: 'internal server error' }) 97 | } 98 | } else { 99 | if (!data) { 100 | if (req.method !== 'DELETE') { 101 | ctx.send(404, { message: 'resource not found' }) 102 | } else { 103 | if (opt.after) { 104 | opt.after(req, res, ctx) 105 | } else { 106 | ctx.send(200, { id: ctx.params.id }, { 'content-type': 'json' }) 107 | } 108 | } 109 | } else { 110 | if (opt.after) { 111 | ctx.data = data 112 | opt.after(req, res, ctx) 113 | } else { 114 | ctx.send(200, JSON.stringify(data), { 'content-type': 'json' }) 115 | } 116 | } 117 | } 118 | }) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # merry-rest 2 | [![npm version](https://img.shields.io/npm/v/merry-rest.svg?style=flat-square)](https://www.npmjs.com/package/merry-rest) [![Build Status](https://img.shields.io/travis/YerkoPalma/merry-rest/master.svg?style=flat-square)](https://travis-ci.org/YerkoPalma/merry-rest) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/feross/standard) 3 | 4 | > serve rest apis with merry and json-schema 5 | 6 | ## Usage 7 | 8 | ```js 9 | var rest = require('merry-rest') 10 | var merry = require('merry') 11 | 12 | var app = merry() 13 | var api = rest(app) 14 | 15 | // db is a levelup instance 16 | var model = api.model(db, 'schema.json') 17 | 18 | api.resource(model, { route: 'schema' }) 19 | api.start() 20 | ``` 21 | 22 | After running the `.start()` method, you will have the following routes 23 | availaible 24 | 25 | ``` 26 | GET /api/v1/schema 27 | GET /api/v1/schema/:id 28 | POST /api/v1/schema 29 | PUT /api/v1/schema/:id 30 | DELETE /api/v1/schema/:id 31 | ``` 32 | 33 | ## API 34 | 35 | ### api = rest(app [, opts]) 36 | 37 | Create a rest api instance. `app` is a [merry][merry] instance and is mandatory. `opts` 38 | is an optional configuration object, availaible options are: 39 | 40 | - **version:** Defaults to 1, specify the version of your api, it will be used 41 | in the route. 42 | - **default:** Set a default route in case of a missmatch. Must be a `function` 43 | like `function (reques, response, context)` 44 | 45 | The returned object has two properties: `api` the merry instance, and `prefix` 46 | the prefix of all the routes. The prefix has the form `'/api/v:version'` 47 | 48 | ### var model = api.model(db, schema) 49 | 50 | Create a [rest-parser][rest-parser] instance using 51 | [level-rest-parser][level-rest-parser] as backend. `db` and `schema` are mandatory. 52 | `db` is a levelup instance where the data is saved and `schema` is a string with 53 | the path to the json file that contains your schema. The rest-parser instance 54 | returned here is used later in the `resource` method. 55 | 56 | ### api.resource(model , opts) 57 | 58 | Generate the rest routes for the given `model`. You must provide a model and 59 | opts argument, where opts can be a string or an object. If opts is a string, 60 | then that will be the route of your model, it it is an object, it can have the 61 | following properties: 62 | 63 | - **route:** The only required property. Must be a string indicating your model 64 | route. 65 | 66 | - **only:** Must be an `array` of `strings`. If defined, set explicity the 67 | methods for which this resource define routes. For example, seting 68 | `{ only: ['GET', 'POST'] }` will set only these routes 69 | 70 | ``` 71 | GET /api/v1/schema 72 | GET /api/v1/schema/:id 73 | POST /api/v1/schema 74 | ``` 75 | 76 | - **except:** Similar to the `only` option, must be an array of strings and 77 | will do the oposite thing, so having `{ except: ['PUT', 'DELETE'] }` as an option 78 | will result in these routes: 79 | 80 | ``` 81 | GET /api/v1/schema 82 | GET /api/v1/schema/:id 83 | POST /api/v1/schema 84 | ``` 85 | 86 | As you can see, you can achieve the same result with both options, but what you 87 | can not do, is define them bot in the same call, 88 | `{ except: ['DELETE'], only: ['GET'] }`, that option has no sense, and it will throw. 89 | 90 | - **before:** Must be a `function`. If defined, will be called _before_ every 91 | route for this resource. It accepts four arguments `req`, `res`, `ctx` and `next` 92 | which are the reques, the response, the merry context and a callback, in this 93 | case, the actual route method. So if you don't call `next(req, res, ctx)` your 94 | actual route wont be called, this is useful if your before hook must cancell the 95 | request 96 | 97 | - **after:** As you might guess, this is like `before` hook, but after. 98 | Difference are that you don't provide a next hook, because there is nothing 99 | next. Also, you must end the request manually here, this is easily done with 100 | merry context object, like [`ctx.send(200, bodyData, headers)`][send] 101 | 102 | ### api.route(method, route, handler) 103 | 104 | Define a special route not set by any of the rest routes. Method is a `string`, 105 | route is also a `string` and handler a function like next in the hooks 106 | (with `req, res, ctx`). 107 | 108 | ### var server = api.start([cb]) 109 | 110 | Start a regular http server with the rest routes defined. Optionally, you can 111 | pass a callback to be run after the server start listening. `cb` must be a 112 | function. 113 | 114 | ## License 115 | [MIT](/license) 116 | 117 | [rest-parser]: https://github.com/karissa/node-rest-parser 118 | [level-rest-parser]: https://github.com/karissa/level-rest-parser 119 | [merry]: https://github.com/shipharbor/merry 120 | [send]: https://github.com/shipharbor/merry#ctxsendstatuscode-data-headers -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var tape = require('tape') 2 | var merry = require('merry') 3 | var memdb = require('memdb') 4 | var got = require('got') 5 | var rest = require('.') 6 | 7 | tape('rest', function (t) { 8 | t.test('defaults', function (assert) { 9 | assert.plan(2) 10 | var app = merry() 11 | var api = rest(app) 12 | assert.equal(api.api, app) 13 | assert.equal(api.prefix, '/api/v1') 14 | // test default 404 15 | }) 16 | t.test('create model', function (assert) { 17 | assert.plan(1) 18 | var app = merry() 19 | var api = rest(app) 20 | var db = memdb() 21 | var model = api.model(db, 'model.json') 22 | assert.ok(model) 23 | }) 24 | t.test('default route', function (assert) { 25 | assert.plan(2) 26 | var app = merry() 27 | var api = rest(app, { default: function (req, res, ctx) { ctx.send(404, { message: 'not found' }) } }) 28 | var server = api.start(function () { 29 | var address = server.address() 30 | got('http://' + address.address + ':' + address.port + '/api/v1/model') 31 | .then(function (response) { 32 | assert.fail(response) 33 | server.close() 34 | }) 35 | .catch(function (error) { 36 | assert.equal(error.response.statusCode, 404) 37 | var data = JSON.parse(error.response.body) 38 | assert.equal(data.message, 'not found') 39 | server.close() 40 | }) 41 | }) 42 | }) 43 | t.test('crud resource', function (assert) { 44 | assert.plan(9) 45 | var app = merry() 46 | var api = rest(app) 47 | var db = memdb() 48 | var model = api.model(db, 'model.json') 49 | var url = null 50 | var modelObject = null 51 | api.resource(model, 'model') 52 | var server = api.start(function () { 53 | var address = server.address() 54 | url = 'http://' + address.address + ':' + address.port + '/api/v1/model' 55 | var body = { 56 | name: 'John Doe', 57 | mail: 'jdoe@mail.com' 58 | } 59 | // POST 60 | got(url, { 61 | method: 'POST', 62 | body: JSON.stringify(body) 63 | }) 64 | .then(function (response) { 65 | assert.equal(response.statusCode, 200) 66 | modelObject = JSON.parse(response.body).data 67 | assert.equal(modelObject.name, 'John Doe') 68 | assert.equal(modelObject.mail, 'jdoe@mail.com') 69 | // GET 70 | got(url) 71 | .then(function (response) { 72 | assert.deepEqual([modelObject], JSON.parse(response.body).data) 73 | // GET :id 74 | got(url + '/' + modelObject.id) 75 | .then(function (response) { 76 | assert.deepEqual(modelObject, JSON.parse(response.body)) 77 | var newBody = { 78 | name: 'James Doe', 79 | mail: 'james.doe@mail.com' 80 | } 81 | // PUT :id 82 | got(url + '/' + modelObject.id, { 83 | method: 'PUT', 84 | body: JSON.stringify(newBody) 85 | }) 86 | .then(function (response) { 87 | assert.equal(response.statusCode, 200) 88 | modelObject = JSON.parse(response.body).data 89 | assert.equal(modelObject.name, 'James Doe') 90 | assert.equal(modelObject.mail, 'james.doe@mail.com') 91 | // DELETE 92 | got(url + '/' + modelObject.id, { 93 | method: 'DELETE' 94 | }) 95 | .then(function (response) { 96 | got(url) 97 | .then(function (response) { 98 | assert.equal(JSON.parse(response.body).data.length, 0) 99 | server.close() 100 | }) 101 | .catch(function (error) { 102 | assert.fail(error) 103 | server.close() 104 | }) 105 | }) 106 | .catch(function (error) { 107 | assert.fail(error) 108 | server.close() 109 | }) 110 | }) 111 | .catch(function (error) { 112 | assert.fail(error) 113 | server.close() 114 | }) 115 | }) 116 | .catch(function (error) { 117 | assert.fail(error) 118 | server.close() 119 | }) 120 | }) 121 | .catch(function (error) { 122 | assert.fail(error) 123 | server.close() 124 | }) 125 | }) 126 | .catch(function (error) { 127 | assert.fail(error) 128 | server.close() 129 | }) 130 | }) 131 | }) 132 | t.test('only option', function (assert) { 133 | assert.plan(2) 134 | var app = merry() 135 | var api = rest(app) 136 | var db = memdb() 137 | var model = api.model(db, 'model.json') 138 | api.resource(model, { route: 'model', only: ['GET'] }) 139 | var server = api.start(function () { 140 | var address = server.address() 141 | got('http://' + address.address + ':' + address.port + '/api/v1/model') 142 | .then(function (response) { 143 | assert.equal(response.statusCode, 200) 144 | var body = { 145 | name: 'John Doe', 146 | mail: 'jdoe@mail.com' 147 | } 148 | got('http://' + address.address + ':' + address.port + '/api/v1/model', { 149 | method: 'POST', 150 | body: JSON.stringify(body) 151 | }) 152 | .then(function (response) { 153 | assert.fail() 154 | server.close() 155 | }) 156 | .catch(function (error) { 157 | assert.equal(error.response.statusCode, 404) 158 | server.close() 159 | }) 160 | }) 161 | .catch(function (error) { 162 | assert.fail(error) 163 | server.close() 164 | }) 165 | }) 166 | }) 167 | t.test('except option', function (assert) { 168 | assert.plan(2) 169 | var app = merry() 170 | var api = rest(app) 171 | var db = memdb() 172 | var model = api.model(db, 'model.json') 173 | api.resource(model, { route: 'model', except: ['POST'] }) 174 | var server = api.start(function () { 175 | var address = server.address() 176 | got('http://' + address.address + ':' + address.port + '/api/v1/model') 177 | .then(function (response) { 178 | assert.equal(response.statusCode, 200) 179 | var body = { 180 | name: 'John Doe', 181 | mail: 'jdoe@mail.com' 182 | } 183 | got('http://' + address.address + ':' + address.port + '/api/v1/model', { 184 | method: 'POST', 185 | body: JSON.stringify(body) 186 | }) 187 | .then(function (response) { 188 | assert.fail() 189 | server.close() 190 | }) 191 | .catch(function (error) { 192 | assert.equal(error.response.statusCode, 404) 193 | server.close() 194 | }) 195 | }) 196 | .catch(function (error) { 197 | assert.fail(error) 198 | server.close() 199 | }) 200 | }) 201 | }) 202 | t.test('before & after option', function (assert) { 203 | assert.plan(3) 204 | var app = merry() 205 | var api = rest(app) 206 | var db = memdb() 207 | var model = api.model(db, 'model.json') 208 | api.resource(model, { route: 'model', before: before, after: after }) 209 | var server = api.start(function () { 210 | var address = server.address() 211 | got('http://' + address.address + ':' + address.port + '/api/v1/model') 212 | .then(function (response) { 213 | assert.equal(response.statusCode, 200) 214 | assert.equal(response.headers['x-hello'], 'world') 215 | server.close() 216 | }) 217 | .catch(function (error) { 218 | assert.fail(error) 219 | server.close() 220 | }) 221 | }) 222 | function before (req, res, ctx, next) { 223 | req.headers['x-hello'] = 'world' 224 | next(req, res, ctx) 225 | } 226 | function after (req, res, ctx) { 227 | assert.pass() 228 | ctx.send(200, ctx.data, req.headers) 229 | server.close() 230 | } 231 | }) 232 | t.test('custom routes', function (assert) { 233 | assert.plan(2) 234 | var app = merry() 235 | var api = rest(app) 236 | api.route('GET', '/api/v1/custom', function (req, res, ctx) { 237 | ctx.send(200, { message: 'custom' }) 238 | }) 239 | var server = api.start(function () { 240 | var address = server.address() 241 | got('http://' + address.address + ':' + address.port + '/api/v1/custom') 242 | .then(function (response) { 243 | assert.equal(response.statusCode, 200) 244 | assert.equal(JSON.parse(response.body).message, 'custom') 245 | server.close() 246 | }) 247 | .catch(function (error) { 248 | assert.fail(error) 249 | server.close() 250 | }) 251 | }) 252 | }) 253 | }) 254 | --------------------------------------------------------------------------------