diff --git a/Gruntfile.js b/Gruntfile.js deleted file mode 100644 index 58ed846..0000000 --- a/Gruntfile.js +++ /dev/null @@ -1,26 +0,0 @@ -module.exports = function(grunt) { - - require('load-grunt-tasks')(grunt); - - grunt.initConfig({ - - jasmine_node: { - options: { - - }, - all: ['test/'] - }, - - watch: { - js: { - files: ['src/**/*', 'test/**/*.js'], - tasks: 'test' - } - } - - }); - - grunt.registerTask('default', ['test']); - grunt.registerTask('test', ['jasmine_node']); - -}; \ No newline at end of file diff --git a/README.md b/README.md index 76b1fd2..a34d5b1 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,116 @@ -Kalamata -======== +## Kalamata + [![Build Status](https://travis-ci.org/mikec/kalamata.svg?branch=master)](https://travis-ci.org/mikec/kalamata) Fully extensible Node.js REST API framework for [Bookshelf.js](http://bookshelfjs.org/) and [Express](http://expressjs.com/) Try the sample app [kalamata-sample](https://github.com/mikec/kalamata-sample) -Install ------------ -`cd` into your project and `npm install kalamata` +## Install + +`npm install kalamata` -What it is ------------ +## What it is -Kalamata helps you build REST APIs that run on Express. It creates some standard CRUD endpoints for you, and allows you to extend these with your application specific logic. +Kalamata helps you build REST APIs that run on Express. +It provide set of middlewares helping you to creat standard CRUD endpoints. +Combining them with your own middlewares allows you to customise these with your application specific logic. -### How it works +## How it works Lets say you have a Bookshelf model called `User` ```js -var User = bookshelf.Model.extend({ +const User = bookshelf.Model.extend({ tableName: 'users' -}); +}) ``` -You can use Kalamata to expose this model to your API +You can use Kalamata to expose this model to express app: ```js -// set up express and kalamata -var app = require('express')(); -var kalamata = require('kalamata'); -var api = kalamata(app); +// import express and kalamata +const express = require('express') +const kalamata = require('kalamata') + +const app = express() // create express app +// create middlewares for user model +const user_middlewarez = kalamata(User) -// expose the User model -api.expose(User); +// create standard CRUD endpoints the User model on app +user_middlewarez.init_app(app) // tell express to listen for incoming requests app.listen(8080, function() { - console.log('Server listening on port 8080'); -}); + console.log('Server listening on port 8080') +}) ``` -which will create these endpoints +which will create these endpoints (see [src/index.js](index.js:init_app)) -| Method | URL | Action | -| :----- | :------------| :---------------------------- | -| GET | `/users` | Get all users | -| GET | `/users/:id` | Get a user by id | -| POST | `/users` | Create a new user | -| PUT | `/users/:id` | Update an existing user | -| DELETE | `/users/:id` | Delete an existing user | +| Method | URL | Action | +| :----- | :------------ | :---------------------------- | +| GET | `/` | Get all users | +| GET | `/:id` | Get a user by id | +| POST | `/` | Create a new user | +| PUT | `/:id` | Update an existing user | +| DELETE | `/:id` | Delete an existing user | +| POST | `/:id/:relation` | Create a new relation | +| GET | `/:id/:relation` | Get a user's relation | -### Extending the default endpoints +### Customising the default endpoints -You can extend the default endpoints by modifying data before or after it is saved, using `before` and `after` hooks. These give you access to the Express request and response objects, and the Bookshelf model instance. +You can customise the default endpoints by swapping default middlewares with your own. Some examples: ```js -/* - * function executes on PUT `/users/:id` - * before updated user data is saved - */ -api.beforeUpdateUser(function(req, res, user) { - // set a propety before user is saved - user.set('updated_on', Date.now()); -}); - +// middleware that sets updated_on attribute before fetched item is saved +function _set_updated_on(req, res, next) { + // set a propety user is saved + req.fetched.set('updated_on', Date.now()) + next() // DON'T forget call next +} +// and use it after fetch and before update +app.put('/:id', user_middlewarez.fetch_middleware, _set_updated_on, user_middlewarez.update_middleware) ``` ```js -/* - * function executes on GET `/users` - * before the collection of users is fetched - */ -api.beforeGetUsers(function(req, res, user) { - // add a where clause to execute when fetching users - user.where({ deleted:false }); -}); - +// middleware that adds another contraint to fetching query +function _omit_deleted(req, res, user) { + req.query.deleted = false + next() // DON'T forget call next +} +app.get('/', user_middlewarez.list_query, _omit_deleted. user_middlewarez.list_middleware) ``` -```js -/* - * function executes on GET `/users/:id` - * after a user is fetched - */ -api.afterGetUser(function(req, res, user) { - if(!isAuthenticatedUser(user)) { - // override the default user data response - res.send({ error: 'access denied' }); - } -}); +If the server receives a `GET /5` request, but you don't want to respond with the user's data: +```js +function _deny_foruser5(req, res, next) { + if(req.fetched.get('id') == 5) { + // break the middleware chain by calling error middleware + return next({ error: "access denied" }) + } + next() // else call next middleware in chain +} +app.get('/:id', user_middlewarez.fetch_middleware, _deny_foruser5, user_middlewarez.detail_middleware) ``` -Configuring the API --------------------- - -##### Initialize `kalamata(expressApp, [options])` - -`apiRoot` option sets a prefix for all API endpoints - -> ```js -> /* -> * prefixes all endpoints with `/api/v1`, -> * for example `/api/v1/users` -> */ -> var api = kalamata(app, { apiRoot: '/api/v1' }); -> ``` - - -##### expose `expose(bookshelfModel, [options])` -`endpointName` option sets the name of the endpoint. +## Default Endpoints -> Defaults to the bookshelf model's tableName property. -> -> ```js -> // sets endpoints up on `/allusers` -> api.expose(User, { endpointName: 'allusers' }); -> ``` +Calling `init_app` on user_middlewares object will create a set of default CRUD endpoints. +Here are the default endpoints, assuming that `user_middlewares.init_app(app)` was called. -`identifier` option sets the name of the identifier param - -> Defaults to `id` -> -> ```js -> /* -> * when identifier is set to `user_id`, -> * a request to `/users/32` will fetch -> * the user with `user_id = 32` -> */ -> api.expose(User, { identifier: 'user_id' }); -> ``` - -`modelName` option sets the name of the model - -> Defaults to the endpoint name capitalized with the `s` removed (`users` -> `User`) - -`collectionName` options sets the name for a collection of model instances - -> Defaults to the endpoint name capitalized (`users` -> `Users`) - - -Default Endpoints -------------------- - -Calling `expose` on a model will create a set of default CRUD endpoints. Here are the default endpoints, assuming that `api.expose(User)` was called. - -#### GET `/users` +#### GET `/` Gets an array of users ```js /* - * GET `/users` + * GET `/` */ // response: @@ -169,25 +122,26 @@ Gets an array of users ``` ##### `where` parameter includes a where clause in the query -`/users?where={name:"user2"}` +`/?where={name:"user2"}` Expects the same parameters as the [bookshelf.js where method](http://bookshelfjs.org/#Model-where) ##### `load` parameter will load related models and include them in the response -`/users?load=orders,favorites` +`/?load=orders,favorites` -Expects a comma delimited string of relations. Calls the [bookshelf.js load method](http://bookshelfjs.org/#Model-load) method with an array of relations. +Expects a comma delimited string of relations. +Calls the [bookshelf.js load method](http://bookshelfjs.org/#Model-load) method with an array of relations. -#### GET `/users/:identifier` +#### GET `/:identifier` Gets a user ```js /* - * GET `/users/2` + * GET `/2` */ // response: @@ -197,17 +151,18 @@ Gets a user ##### `load` parameter will load related models and include them in the response -`/user/2?load=orders,favorites` +`/2?load=orders,favorites` -Expects a comma delimited string of relations. Calls the [bookshelf.js load method](http://bookshelfjs.org/#Model-load) method with an array of relations. +Expects a comma delimited string of relations. +Calls the [bookshelf.js load method](http://bookshelfjs.org/#Model-load) method with an array of relations. -#### POST `/users` +#### POST `/` Creates a user ```js /* - * POST `/users` { "name": "user4" } + * POST `/` { "name": "user4" } */ // response: @@ -216,13 +171,13 @@ Creates a user ``` -#### PUT `/users/:identifier` +#### PUT `/:identifier` Modifies a user ```js /* - * PUT `/users/2` { "name": "user2 MODIFIED" } + * PUT `/2` { "name": "user2 MODIFIED" } */ // response: @@ -231,13 +186,13 @@ Modifies a user ``` -#### DELETE `/users/:identifier` +#### DELETE `/:identifier` Deletes a user ```js /* - * DELETE `/users/3` + * DELETE `/3` */ // response: @@ -246,13 +201,13 @@ true ``` -#### GET `/users/:identifier/things` +#### GET `/:identifier/things` Gets an array of things related to a user ```js /* - * GET `/users/2/things` + * GET `/2/things` */ // response: @@ -261,144 +216,16 @@ Gets an array of things related to a user ``` -#### POST `/users/:identifier/things` +#### POST `/:identifier/things` Relates a thing to a user ```js /* - * POST `/users/2/things` { "id": "3" } + * POST `/2/things` { "id": "3" } */ // response: {} ``` - -Hooks -------- - -Hooks let you extend and override default endpoint behaviors. - -`before` hooks are executed before the default database action, such as fetch, save, or delete. `after` hooks are executed after all database actions are complete. - -Hook names are generated based on endpoint configurations. This list is based on a `/users` endpoint where `modelName = User` and `collectionName = Users` - -| Hook Name | Request | Arguments | -| :-------------------------| :------------------------ | :------------------------------------ | -| `beforeGetUsers` | GET `/users` | [req, res, userModel] | -| `afterGetUsers` | GET `/users` | [req, res, userCollection] | -| `beforeGetUser` | GET `/users/:id` | [req, res, userModel] | -| `afterGetUser` | GET `/users/:id` | [req, res, userModel] | -| `beforeCreateUser` | POST `/users` | [req, res, userModel] | -| `afterCreateUser` | POST `/users` | [req, res, userModel] | -| `beforeUpdateUser` | PUT `/users/:id` | [req, res, userModel] | -| `afterUpdateUser` | PUT `/users/:id` | [req, res, userModel] | -| `beforeDeleteUser` | DELETE `/users/:id` | [req, res, userModel] | -| `afterDeleteUser` | DELETE `/users/:id` | [req, res, userModel] | -| `beforeGetRelatedThings` | GET `/users/:id/things` | [req, res, thingModel] | -| `afterGetRelatedThings` | GET `/users/:id/things` | [req, res, thingsCollection] | -| `beforeRelatedThing` | POST `/users/:id/things` | [req, res, userModel] | -| `afterRelateThing` | POST `/users/:id/things` | [req, res, userModel, thingModel] | - -`req` and `res` are an Express [request](http://expressjs.com/4x/api.html#request) and [response](http://expressjs.com/4x/api.html#response) - -`userModel` is an instance of a [bookshelf model](http://bookshelfjs.org/#Model) - -`userCollection` is an instance of a [bookshelf collection](http://bookshelfjs.org/#Collection) - -### Adding hooks - -```js -api.beforeCreateUser(function(req, res, user) { - // do stuff before the user is created -}); - -api.afterCreateUser(function(req, res, user) { - // do stuff after the user is created -}); -``` - -### What hooks can do - -Because you have the full power of Express and Bookshelf within your hooks, you have total control over how the Kalamata endpoints behave. Here are some examples: - -#### Manipulating data - -If the server receives a `POST /users { "name":"Joey" }` request: - -```js -/* - * The user model can be manipulated before it is saved. - * - * When this hook is finished executing, - * `{ "name":"Joey McGee" }` will be saved - * - */ -api.beforeCreateUser(function(req, res, user) { - var userName = user.get('name'); - user.set({name:userName + ' McGee'}); -}); -``` - -```js -/* - * After the user is created, the response can be manipulated. - * - * When this hook is finished executing, the server will - * respond with `{ "name":"Joey", "lastName":"McGee" }` - * - * The changes to the user will not be saved, because this hook - * is executed after the user is saved - * - */ -api.afterCreateUser(function(req, res, user) { - var nameSplit = user.get('name').split(' '); - user.set({ - name: nameSplit[0], - lastName: nameSplit[1] - }); -}); -``` - -#### Cancelling default actions - -If the server receives a `GET /user/5` request, but you don't want to respond with the user's data: - -```js -/* - * Send a response from the before hook - * - * Once a response is sent, Kalamata will not execute - * any of the default actions, including after hooks. - * - */ -api.beforeGetUser(function(req, res, user) { - if(user.get('id') == 5) { - res.send({ error: "access denied" }); - } -}); -api.afterGetUser(function(req, res, user) { - // will not be executed on requests for `user/5` -}); - -``` - -#### Overriding default actions - -If the server receives a `DELETE /user/5` request, Kalamata will call `user.destroy()` by default. You can override this default behavior by returning a promise from the before hook: - -```js -/* - * Call a function that returns a promise, and have the - * hook function return the result of that promise - * - * Kalamata will not execute the default action, - * which in this case would have been `user.destroy()` - * - * Flag the user as deleted with a `deleted=true` property - */ -api.beforeDeleteUser(function(req, res, user) { - return user.save({ deleted: true }); -}); - diff --git a/package.json b/package.json index b26aa75..5a34d48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kalamata", - "version": "0.1.3", + "version": "2.0.0", "description": "Extensible REST API for Express + Bookshelf.js", "homepage": "https://github.com/mikec/kalamata", "bugs": "https://github.com/mikec/kalamata/issues", @@ -19,20 +19,22 @@ ], "peerDependencies": { "express": "^4.10.0", - "bookshelf": "^0.10" + "body-parser": "^1.9.0", + "bookshelf": "vencax/bookshelf" }, "devDependencies": { - "grunt": "0.4.5", - "grunt-contrib-watch": "0.6.1", - "grunt-jasmine-node": "git://github.com/fiznool/grunt-jasmine-node.git#c773421b608ce944454cb540a6e66575d2df09c6", - "load-grunt-tasks": "0.6.0" + "chai": "^3.5.0", + "chai-http": "^3.0.0", + "coffee-script": "^1.12.5", + "knex": "^0.12.6", + "mocha": "^2.3.4", + "sqlite3": "^3.1.8" }, "dependencies": { - "body-parser": "^1.9.0", "bluebird": "^3" }, "scripts": { - "test": "grunt" + "test": "mocha --compilers coffee:coffee-script/register test" }, "main": "src/index.js" } diff --git a/src/basic.js b/src/basic.js new file mode 100644 index 0000000..6b9bcce --- /dev/null +++ b/src/basic.js @@ -0,0 +1,92 @@ + +module.exports = function(model) { + + function _list_middleware(req, res, next) { + let mod = new model() + if(req.query) { + mod = mod.query({where: req.query}) + } + if(req.sortCol) { + mod = mod.orderBy(req.sortCol, req.sortOrder) + } + const fetchopts = {} + if (req.loadquery) { + fetchopts.withRelated = req.loadquery + } + if (req.page) { + fetchopts.page = req.page + fetchopts.pageSize = req.pagesize + } + if (req.columns4fetch) { + fetchopts.columns = req.columns4fetch + } + const fetchMethod = req.page === undefined ? mod.fetchAll : mod.fetchPage + fetchMethod.bind(mod)(fetchopts).then(function(collection) { + if (collection.pagination) { + res.set('x-total-count', collection.pagination.rowCount) + } + res.json(collection) + next() + }) + .catch(next) + } + + function _detail_middleware(req, res, next) { + res.json(req.fetched) // just send the fetched + next() + } + + function _create_middleware(req, res, next) { + var newitem = new model(req.body) + newitem.save() + .then(function(savedModel) { + req.saveditem = savedModel + res.status(201).json(savedModel) + next() + }) + .catch(next) + } + + function _fetch_middleware(req, res, next) { + var mod = new model({id: req.params.id}) + const fetchopts = { + require: true + } + if (req.loadquery) { + fetchopts.withRelated = req.loadquery + } + mod.fetch(fetchopts) + .then(function(fetched) { + req.fetched = fetched + next() + }) + .catch(next) + } + + function _update_middleware(req, res, next) { + req.fetched.save(req.body) + .then(function(saved) { + res.status(200).json(saved) + }) + .catch(next) + } + + function _delete_middleware(req, res, next) { + req.fetched.destroy() + .then(function(saved) { + res.status(200).send('deleted') + next() + }) + .catch(next) + } + + return { + fetch: _fetch_middleware, + list: _list_middleware, + detail: _detail_middleware, + create: _create_middleware, + update: _update_middleware, + delete: _delete_middleware + } + +} diff --git a/src/index.js b/src/index.js index d6fe4a6..5baad35 100644 --- a/src/index.js +++ b/src/index.js @@ -1,483 +1,29 @@ -var bodyParser = require('body-parser'); -var Promise = require("bluebird"); -var app, options; -var hooks = {}; -var modelMap = {}; -var identifierMap = {}; -var modelNameMap = {}; -var collectionNameMap = {}; -var kalamata = module.exports = function(_app_, _options_) { - app = _app_; - options = _options_; - - app.use(bodyParser.json()); - - if(!options) options = {}; - if(!options.apiRoot) options.apiRoot = '/'; - else options.apiRoot = '/' + options.apiRoot.replace(/^\/|\/$/g, '') + '/'; - - return kalamata; -}; - -kalamata.expose = function(model, _opts_) { - - var validOpts = { - identifier: true, - endpointName: true, - modelName: true, - collectionName: true - }; - - var opts = {}; - if(!opts.identifier) opts.identifier = 'id'; - if(!opts.endpointName) opts.endpointName = model.forge().tableName; - - for(var p in _opts_) { - if(validOpts[p]) { - opts[p] = _opts_[p]; - } else { - throw new Error('Invalid option: ' + p); - } - } - - modelMap[opts.endpointName] = model; - identifierMap[opts.endpointName] = opts.identifier; - - hooks[opts.endpointName] = { - before: hookArrays(), - after: hookArrays() - }; - - var beforeHooks = hooks[opts.endpointName].before; - var afterHooks = hooks[opts.endpointName].after; - - opts.collectionName = opts.collectionName ? - capitalize(opts.collectionName) : - collectionName(opts.endpointName); - opts.modelName = opts.modelName ? - capitalize(opts.modelName) : - modelName(opts.endpointName); - - var modelNameLower = decapitalize(opts.modelName); - var collectionNameLower = decapitalize(opts.collectionName); - - modelMap[modelNameLower] = - modelMap[collectionNameLower] = model; - identifierMap[modelNameLower] = - identifierMap[collectionNameLower] = opts.identifier; - modelNameMap[collectionNameLower] = modelNameLower; - collectionNameMap[modelNameLower] = collectionNameLower; - - hooks[modelNameLower] = hooks[collectionNameLower] = { - before: hookArrays(), - after: hookArrays() - }; - - var beforeHooks = hooks[modelNameLower].before; - var afterHooks = hooks[modelNameLower].after; - - createHookFunctions(); - configureEndpoints(); - - function configureEndpoints() { - - app.get(options.apiRoot + opts.endpointName, function(req, res, next) { - var mod; - if(req.query.where) { - var w; - try { - w = parseJSON(req.query.where); - } catch(err) { - throw new Error('Could not parse JSON: ' + req.query.where); - } - mod = new model().where(w); - } else { - mod = new model(); - } - - var beforeResult = runHooks(beforeHooks.getCollection, [req, res, mod]); - if(res.headersSent) return; - - var promise = beforeResult.promise || mod.fetchAll(getFetchParams(req, res)); - promise.then(function(collection) { - var afterResult = runHooks(afterHooks.getCollection, [req, res, collection]); - return afterResult.promise || collection; - }).then(function(collection) { - sendResponse(res, collection.toJSON()); - }).catch(next); - }); - - app.get(options.apiRoot + opts.endpointName + '/:identifier', - function(req, res, next) { - var mod = new model(getModelAttrs(req)); - - var beforeResult = runHooks(beforeHooks.get, [req, res, mod]); - if(res.headersSent) return; - - var promise = beforeResult.promise || mod.fetch(getFetchParams(req, res)); - promise.then(function(m) { - return checkModelFetchSuccess(req, m); - }).then(function(m) { - var afterResult = runHooks(afterHooks.get, [req, res, m]); - return afterResult.promise || m; - }).then(function(m) { - sendResponse(res, m); - }).catch(next); - }); - - app.get(options.apiRoot + opts.endpointName + '/:identifier/:relation', - function(req, res, next) { - var mod = new model(getModelAttrs(req)); - mod.fetch({ - withRelated: getWithRelatedArray([req.params.relation], req, res) - }).then(function(m) { - return checkModelFetchSuccess(req, m); - }).then(function(m) { - return m.related(req.params.relation); - }).then(function(related) { - var afterResult = {}; - var relHooks = hooks[req.params.relation]; - if(relHooks) { - afterResult = runHooks( - hooks[req.params.relation].after.getRelated, - [req, res, related, mod]); - } - return afterResult.promise || related; - }).then(function(related) { - sendResponse(res, related); - }).catch(next); - }); - - app.post(options.apiRoot + opts.endpointName, function(req, res, next) { - var mod = new model(req.body); - - var beforeResult = runHooks(beforeHooks.create, [req, res, mod]); - if(res.headersSent) return; - - var promise = beforeResult.promise || mod.save(); - promise.then(function(m) { - if(m) { - var afterResult = runHooks(afterHooks.create, [req, res, m]); - return afterResult.promise || m; - } - }).then(function(m) { - if(m) { - sendResponse(res, m.toJSON()); - } - }).catch(next); - }); - - app.post(options.apiRoot + opts.endpointName + '/:identifier/:relation', - function(req, res, next) { - var rel = req.params.relation; - var rModel = modelMap[rel]; - var rId = identifierMap[rel]; - var mod = new model(getModelAttrs(req)); - var relMod; - - var beforeResult = runMultiHooks( - [hooks[req.params.relation].before.relate, - [req, res, mod]], - [beforeHooks.relate, - [req, res, mod]]); - if(res.headersSent) return; - - var promise; - if(beforeResult.promise) { - promise = beforeResult.promise; - } else { - promise = mod.fetch(); - } - - promise.then(function(m) { - if(req.body[rId]) { - // fetch and add an existing model - return (new rModel(req.body)).fetch().then(function(rMod) { - if(rMod) { - relMod = rMod; - var relCollection = m.related(rel); - if(relCollection.create) { - // for hasMany relations - return relCollection.create(rMod); - } else { - // for belongsTo relations, reverse it - return rMod.related(opts.endpointName).create(m); - } - } else { - throw new Error('Create relationship failed: ' + - 'Could not find ' + rel + - ' model ' + JSON.stringify(req.body)); - } - }); - } else { - throw new Error('Create relationship failed: ' + - rId + ' property not provided'); - } - }).then(function() { - var afterResult = runMultiHooks( - [hooks[req.params.relation].after.relate, - [req, res, mod, relMod]], - [afterHooks.relate, - [req, res, mod, relMod]]); - return afterResult.promise || null; - return null; - }).then(function() { - sendResponse(res, null); - }).catch(next); - }); - - app.put(options.apiRoot + opts.endpointName + '/:identifier', - function(req, res, next) { - new model(getModelAttrs(req)).fetch().then(function(m) { - if(m) m.set(req.body); - var beforeResult = runHooks(beforeHooks.update, [req, res, m]); - if(!res.headersSent) { - if(m) { - return beforeResult.promise || m.save(); - } else { - return checkModelFetchSuccess(req, m); - } - } - }).then(function(m) { - if(m) { - var afterResult = runHooks(afterHooks.update, [req, res, m]); - return afterResult.promise || m; - } - }).then(function(m) { - if(m) { - sendResponse(res, m.toJSON()); - } - }).catch(next); - }); - - app.delete(options.apiRoot + opts.endpointName + '/:identifier', - function(req, res, next) { - new model(getModelAttrs(req)).fetch().then(function(m) { - var beforeResult = runHooks(beforeHooks.del, [req, res, m]); - if(!res.headersSent) { - if(m) { - return beforeResult.promise || m.destroy(); - } else { - return checkModelFetchSuccess(req, m); - } - } - }).then(function(m) { - if(m) { - var afterResult = runHooks(afterHooks.del, [req, res, m]); - return afterResult.promise || m; - } - }).then(function() { - sendResponse(res, true); - }).catch(next); - }); - - app.delete(options.apiRoot + opts.endpointName + '/:identifier/:relation', - function(req, res, next) { - var rel = req.params.relation; - var mod = new model(getModelAttrs(req)); - mod.fetch().then(function(m) { - var fKey = m[rel]().relatedData.foreignKey; - return m.set(fKey, null).save(); - }).then(function() { - sendResponse(res, true); - }).catch(next); - }); - - app.delete(options.apiRoot + opts.endpointName + '/:identifier/:relation/:rIdentifier', - function(req, res, next) { - var rel = req.params.relation; - var rModel = modelMap[rel]; - var rId = identifierMap[rel]; - var mod = new model(getModelAttrs(req)); - var relMod; - - mod.fetch().then(function(m) { - var rModAttrs = {}; - rModAttrs[rId] = req.params.rIdentifier; - return (new rModel(rModAttrs)).fetch().then(function(rMod) { - if(rMod) { - var modelName = modelNameMap[opts.endpointName]; - var fKey = rMod[modelName]().relatedData.foreignKey; - return rMod.set(fKey, null).save(); - } else { - throw new Error('Delete relationship failed: ' + - 'Could not find ' + rel + - ' model ' + JSON.stringify(rModAttrs)); - } - }); - }).then(function() { - sendResponse(res, true); - }).catch(next); - - }); - } - - function runMultiHooks() { - var promiseResults = []; - for(var i in arguments) { - var res = runHooks.apply(null, arguments[i]); - if(res.promise) { - promiseResults.push(res.promise); - } - } - if(promiseResults.length > 0) { - var ret = Promise.all(promiseResults).then(function() { - var args = arguments[0]; - return new Promise(function(resolve) { - resolve.apply(null, args); - }); - }); - return { promise: ret }; - } else { - return {}; - } - } - - function runHooks(fnArray, args) { - var result; - for(var i in fnArray) { - result = fnArray[i].apply(null, args); - } - if(result && result.then) { - return { - promise: result - }; - } else { - return {}; - } - } - - function hookArrays() { - return { - get: [], - getCollection: [], - getRelated: [], - create: [], - update: [], - del: [], - relate: [] - }; - } - - function createHookFunctions() { - createHookFunction('beforeGet' + opts.collectionName, - 'before', 'getCollection'); - createHookFunction('beforeGetRelated' + opts.collectionName, - 'before', 'getRelated'); - createHookFunction('beforeGet' + opts.modelName, 'before', 'get'); - createHookFunction('beforeCreate' + opts.modelName, 'before', 'create'); - createHookFunction('beforeUpdate' + opts.modelName, 'before', 'update'); - createHookFunction('beforeDelete' + opts.modelName, 'before', 'del'); - createHookFunction('beforeRelate' + opts.modelName, 'before', 'relate'); - createHookFunction('afterGet' + opts.collectionName, - 'after', 'getCollection'); - createHookFunction('afterGetRelated' + opts.collectionName, - 'after', 'getRelated'); - createHookFunction('afterGet' + opts.modelName, 'after', 'get'); - createHookFunction('afterCreate' + opts.modelName, 'after', 'create'); - createHookFunction('afterUpdate' + opts.modelName, 'after', 'update'); - createHookFunction('afterDelete' + opts.modelName, 'after', 'del'); - createHookFunction('afterRelate' + opts.modelName, 'after', 'relate'); - } - - function createHookFunction(fnName, prefix, type) { - kalamata[fnName] = hookFn(prefix, type, fnName); - } - - function hookFn(prefix, type, fnName) { - if(type) { - return function(fn) { - fn.__name = fnName; - hooks[opts.endpointName][prefix][type].push(fn); - }; - } else { - return function(fn) { - fn.__name = fnName; - for(var i in hooks[prefix]) { - hooks[opts.endpointName][prefix][i].push(fn); - } - }; - } - } - - function checkModelFetchSuccess(req, m) { - if(!m) { - throw new Error( - req.method + ' ' + req.url + ' failed: ' + - opts.identifier + ' = ' + req.params.identifier + - ' not found' - ); - } - return m; - } - - function getModelAttrs(req) { - var attrs; - if(req.params.identifier) { - attrs = {}; - attrs[opts.identifier] = req.params.identifier; - } - return attrs; - } - - function getWithRelatedArray(related, req, res) { - var relArray = []; - for(var i in related) { - var r = related[i]; - var relHooks = hooks[r]; - if(relHooks) { - var relObj = {}; - relObj[r] = function(qb) { - runHooks(relHooks.before.getRelated, [req, res, qb]); - }; - relArray.push(relObj); - } else { - relArray.push(r); - } - } - return relArray; - } - - function getFetchParams(req, res) { - return req.query.load ? { - withRelated: getWithRelatedArray(req.query.load.split(','), req, res) - } : null; - } - - function sendResponse(response, sendData) { - if(!response.headersSent) response.send(sendData); - } - - function collectionName(endpointName) { - endpointName = capitalize(endpointName); - endpointName += (endpointName.slice(-1) == 's' ? '' : 'Collection'); - return endpointName; - } - - function modelName(endpointName) { - endpointName = (endpointName.slice(-1) == 's' ? - endpointName.substr(0,endpointName.length - 1) : - endpointName); - return capitalize(endpointName); - } - - function decapitalize(str) { - return str.charAt(0).toLowerCase() + str.slice(1); - } - - function capitalize(str) { - return str.charAt(0).toUpperCase() + str.slice(1); - } - - function parseJSON(str) { - return JSON.parse(fixJSONString(str)); - } - - function fixJSONString(str) { - return str.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": '); - } - - return kalamata; - -}; \ No newline at end of file +module.exports = function(model, opts) { + + if (! opts.createError) { + throw new Error('mandatory function createError not provided in opts :/') + } + + const q = require('./preparation')(opts) + const basic = require('./basic')(model) + const related = require('./related')(model) + + function _init_app(app) { + app.get('/', q.paging_q, q.sorting_q, q.load_q, q.attrs_q, basic.list) + app.get('/:id', q.load_q, basic.fetch, basic.detail) + app.post('/', basic.create) + app.put('/:id', basic.fetch, basic.update) + app.delete('/:id', basic.fetch, basic.delete) + // relations + app.get('/:id/:relation', + basic.fetch, q.paging_q, q.sorting_q, q.attrs_q, + related.fetch_rel, related.get_rel + ) + app.post('/:id/:relation', basic.fetch, related.create_rel) + app.put('/:id/:relation', basic.fetch, related.fetch_rel, related.update_rel) + app.delete('/:id/:relation', basic.fetch, related.fetch_rel, related.delete_rel) + } + + return Object.assign({init_app: _init_app}, q, basic, related) +} diff --git a/src/preparation.js b/src/preparation.js new file mode 100644 index 0000000..4bd7a2b --- /dev/null +++ b/src/preparation.js @@ -0,0 +1,87 @@ + +module.exports = function(opts) { + + function _load_query(req, res, next) { + try { + req.loadquery = req.query._load ? req.query._load.split(',') : [] + delete req.query._load + } catch(err) { + return next(opts.createError('could not parse query._load')) + } + next() + } + + function _extract_paging(req) { // default pageinfo extractor + if (req.query._page && req.query._pagesize) { + const i = { + page: req.query._page, + pagesize: req.query._pagesize + } + delete req.query._page + delete req.query._pagesize + return i + } + } + + function _paging_query(req, res, next) { + const pinfo = opts.pageinfo_extractor ? + opts.pageinfo_extractor(req) : _extract_paging(req) + if (pinfo) { + const page = parseInt(pinfo.page) + if (isNaN(page) || page <= 0) { + return next(opts.createError('wrong page')) + } + const pagesize = parseInt(pinfo.pagesize) + if (isNaN(pagesize) || pagesize <= 0) { + return next(opts.createError('wrong pagesize')) + } + req.page = page + req.pagesize = pagesize + } + next() + } + + function _extract_sorting(req) { // default sortinginfo extractor + if (req.query._sortCol && req.query._sortOrder) { + const i = { + sortCol: req.query._sortCol, + sortOrder: req.query._sortOrder + } + delete req.query._sortCol + delete req.query._sortOrder + return i + } + } + + function _sorting_query(req, res, next) { + const info = opts.sortinfo_extractor ? + opts.sortinfo_extractor(req) : _extract_sorting(req) + if (info) { + if (! info.sortCol || info.sortCol.length === 0) { + return next(opts.createError('wrong sorting column')) + } + if (! info.sortOrder.match(/^ASC$|^DESC$/)) { + return next(opts.createError('wrong sort order')) + } + req.sortCol = info.sortCol + req.sortOrder = info.sortOrder + } + next() + } + + function _attrs_query(req, res, next) { + if (req.query._attrs) { + req.columns4fetch = req.query._attrs.split(',') + delete req.query._attrs + } + next() + } + + return { + attrs_q: _attrs_query, + load_q: _load_query, + paging_q: _paging_query, + sorting_q: _sorting_query + } + +} diff --git a/src/related.js b/src/related.js new file mode 100644 index 0000000..34ab9c4 --- /dev/null +++ b/src/related.js @@ -0,0 +1,74 @@ + +module.exports = function(model) { + + function _get_related_middleware(req, res, next) { + if (req.fetchedrelated.pagination) { + res.set('x-total-count', req.fetchedrelated.pagination.rowCount) + } + res.json(req.fetchedrelated) // just JSON back req.fetchedrelated + next() + } + + function _create_relation_middleware(req, res, next) { + const relation = req.fetched.related(req.params.relation) + // for hasMany relations + const newitem = new relation.model(req.body) + relation.create(newitem) + .then(function(savedModel) { + req.savedModel = savedModel + res.status(201).json(savedModel) + next() + }) + .catch(next) + } + + function _delete_relation_middleware(req, res, next) { + req.fetchedrelated.invokeThen('destroy') + .then((deleted) => { + res.status(200).send('deleted') + next() + }) + .catch(next) + } + + function _update_relation_middleware(req, res, next) { + req.fetchedrelated.map((i) => { + i.set(req.body) // updated values + }) + req.fetchedrelated.invokeThen('save') + .then((saved) => { + res.status(200).json(req.fetchedrelated) + next() + }) + .catch(next) + } + + function _fetch_related_middleware(req, res, next) { + const relation = req.fetched.related(req.params.relation) + const where = req.query || {} + where[relation.relatedData.foreignKey] = relation.relatedData.parentFk + let q = relation.model.collection().query({where: where}) + if(req.sortCol) { + q = q.orderBy(req.sortCol, req.sortOrder) + } + const fetchopts = (req.page) ? {page: req.page, pageSize: req.pagesize} : {} + if (req.columns4fetch) { + fetchopts.columns = req.columns4fetch + } + const fetch = (req.page !== undefined) ? q.fetchPage : q.fetch + fetch.bind(q)(fetchopts).then((found) => { + req.fetchedrelated = found + next() + }) + .catch(next) + } + + return { + fetch_rel: _fetch_related_middleware, + get_rel: _get_related_middleware, + create_rel: _create_relation_middleware, + update_rel: _update_relation_middleware, + delete_rel: _delete_relation_middleware + } + +} diff --git a/test/db.coffee b/test/db.coffee new file mode 100644 index 0000000..14aa819 --- /dev/null +++ b/test/db.coffee @@ -0,0 +1,29 @@ +Knex = require('knex') + +debugopts = + client: 'sqlite3' + connection: + filename: ':memory:' + debug: true + migrations: + directory: __dirname + '/migrations' + +knex = Knex(debugopts) +bookshelf = require('bookshelf')(knex) +bookshelf.plugin('pagination') + +Thing = bookshelf.Model.extend + tableName: 'things' + user: () -> + return this.belongsTo(User) + +User = bookshelf.Model.extend + tableName: 'users' + tools: () -> + return this.hasMany(Thing, 'user_id') + +knex.models = + User: User + Thing: Thing + +module.exports = knex diff --git a/test/del.coffee b/test/del.coffee new file mode 100644 index 0000000..2255ca2 --- /dev/null +++ b/test/del.coffee @@ -0,0 +1,39 @@ + +chai = require('chai') +should = chai.should() + +module.exports = (g)-> + + r = chai.request(g.baseurl) + + describe 'delete routes', -> + + it 'must delete relation (remove magicwand from gandalf)', () -> + # remove magicwand + return r.delete("/#{g.gandalfID}/tools?type=supermagicwand") + .then (res) -> + res.should.have.status(200) + # verify gandalf is toolless + return r.get("/#{g.gandalfID}?_load=tools") + .then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.tools.length.should.eql 1 + + it 'must delete user', (done) -> + r.delete("/#{g.gandalfID}") + .end (err, res) -> + return done(err) if err + res.should.have.status(200) + r.get("/#{g.gandalfID}").end (err, res) -> + return done('still exist') if not err + res.should.have.status(404) + done() + return + + it 'must 404 on notexisting user', (done) -> + r.delete("/hovno").end (err, res) -> + return done('shall 404 but have not') if not err + res.should.have.status(404) + done() + return diff --git a/test/delete-spec.js b/test/delete-spec.js deleted file mode 100644 index 2d0bc4b..0000000 --- a/test/delete-spec.js +++ /dev/null @@ -1,153 +0,0 @@ -describe('DELETE request to delete an item', function() { - - beforeEach(function() { - this.mockApp = new MockApp(); - this.k = requireKalamata()(this.mockApp); - }); - - describe('for an item that exists', function() { - - beforeEach(function() { - this.mockRequest = new MockRequest({ - params: { identifier: '1' } - }); - this.mockResponse = new MockResponse(); - var m = MockModel.get(); - mockModel = MockModel.get('items', { - fetch: function() { - return new MockPromise([new m()]); - } - }); - spyOn(this.mockResponse, 'send'); - this.k.expose(mockModel); - this.mockApp.deleteHandlers['/items/:identifier']( - this.mockRequest, - this.mockResponse - ); - }); - - it('should set attributes on the model that will be fetched', function() { - expect(mockModel.modelInstances[0].attributes) - .toEqual({ id : '1' }); - }); - - it('should respond with true', function() { - expect(this.mockResponse.send.calls.argsFor(0)[0]) - .toEqual(true); - }); - - }); - - describe('for an item that does not exist', function() { - - beforeEach(function() { - var $this = this; - this.mockNextFn = function() {}; - spyOn(this, 'mockNextFn'); - this.p = new MockPromise(); - this.k.expose(MockModel.get('items', { - fetch: function() { - return $this.p; - } - })); - this.mockApp.deleteHandlers['/items/:identifier']( - new MockRequest({ - params: { identifier: 1 }, - method: 'DELETE', - url: 'mock.com/items/1' - }), - new MockResponse(), - this.mockNextFn - ); - }); - - it('should call next with an item not found error', function() { - expect(this.mockNextFn).toHaveBeenCalled(); - expect(this.mockNextFn.calls.argsFor(0)[0].message) - .toEqual('DELETE mock.com/items/1 failed: id = 1 not found'); - }); - - }); - - describe('with a before hook', function() { - - describe('that runs without executing any code', function() { - - hookExecTest('before', 'DeleteItem', '/items/:identifier'); - - it('should pass the fetch result argument to the hook', function() { - expect(this.hookFn.calls.argsFor(0)[2]) - .toBe(this.mockFetchResult); - }); - - it('should call destroy on the fetch result', function() { - expect(this.mockFetchResult.destroy).toHaveBeenCalled(); - }); - - }); - - describe('that throws an error', function() { - - hookErrorTest('before', 'DeleteItem', '/items/:identifier', true); - - it('should not call destroy on the fetch result', function() { - expect(this.mockFetchResult.destroy).not.toHaveBeenCalled(); - }); - - }); - - describe('that sends a response', function() { - - beforeEach(function() { - setupHook.call( - this, 'before', 'DeleteItem', '/items/:identifier', - function(req, res) { res.send(true); } - ); - }); - - it('should not call destroy on the fetch result', function() { - expect(this.mockFetchResult.destroy).not.toHaveBeenCalled(); - }); - - }); - - describe('that returns a promise', function() { - - hookPromiseTest('before', 'DeleteItem', '/items/:identifier'); - - it('should not call destroy on the fetch result', function() { - expect(this.mockFetchResult.destroy).not.toHaveBeenCalled(); - }); - - }); - - }); - - describe('with an after hook', function() { - - describe('that runs without executing any code', function() { - - hookExecTest('after', 'DeleteItem', '/items/:identifier'); - - it('should pass the fetch result to the hook', function() { - expect(this.hookFn.calls.argsFor(0)[2]) - .toBe(this.mockFetchResult); - }); - - }); - - describe('that throws an error', function() { - hookErrorTest('after', 'DeleteItem', '/items/:identifier', true); - }); - - describe('that sends a response', function() { - singleResponseHookTest('after', 'DeleteItem', '/items/:identifier'); - }); - - describe('that returns a promise', function() { - hookPromiseTest('after', 'DeleteItem', '/items/:identifier'); - }); - - }); - -}); \ No newline at end of file diff --git a/test/get-collection-spec.js b/test/get-collection-spec.js deleted file mode 100644 index b2b9228..0000000 --- a/test/get-collection-spec.js +++ /dev/null @@ -1,243 +0,0 @@ -describe('GET request for collection', function() { - - beforeEach(function() { - this.mockApp = new MockApp(); - this.k = requireKalamata()(this.mockApp); - }); - - describe('without any query params', function() { - - beforeEach(function() { - var $this = this; - this.mockCollectionJSON = ['one','two','three']; - this.mockCollection = { - toJSON: function() { return $this.mockCollectionJSON; } - }; - this.mockModel = MockModel.get('items', { - fetchAll: function() { - return new MockPromise([$this.mockCollection]); - } - }); - this.k.expose(this.mockModel); - this.mockResponse = new MockResponse(); - spyOn(this.mockResponse, 'send'); - this.mockApp.getHandlers['/items']( - new MockRequest(), - this.mockResponse - ); - }); - - it('should instantiate a new model', function() { - expect(this.mockModel.modelInstances.length).toEqual(1); - }); - - it('should respond with the result of the fetchAll promise', function() { - expect(this.mockResponse.send.calls.argsFor(0)[0]) - .toEqual(this.mockCollectionJSON); - }); - - }); - - describe('when fetchAll throws an error', function() { - - beforeEach(function() { - var $this = this; - this.mockErr = new Error('mock error'); - this.mockModel = MockModel.get('items', { - fetchAll: function() { - return new MockFailPromise($this.mockErr); - } - }); - this.k.expose(this.mockModel); - this.mockNextFn = function() {}; - spyOn(this, 'mockNextFn'); - this.mockApp.getHandlers['/items']( - new MockRequest(), - new MockResponse(), - this.mockNextFn - ); - }); - - it('should call next with the error', function() { - expect(this.mockNextFn).toHaveBeenCalled(); - expect(this.mockNextFn.calls.argsFor(0)[0]).toBe(this.mockErr); - }); - - }); - - describe('with the \'where\' query param', function() { - - describe('set to a valid JSON object', function() { - - beforeEach(function() { - setupWhereQueryTests - .call(this, '{"firstname":"mike","lastname":"c"}'); - }); - - it('should call \'where\' on the model and pass the parsed query value', - function() { - expect(this.w.firstname).toEqual('mike'); - expect(this.w.lastname).toEqual('c'); - }); - - }); - - describe('set to a JSON object without quotes around property names', - function() { - - beforeEach(function() { - setupWhereQueryTests.call(this, '{firstname:"mike",lastname:"c"}'); - }); - - it('should call \'where\' on the model and pass the parsed query value', - function() { - expect(this.w.firstname).toEqual('mike'); - expect(this.w.lastname).toEqual('c'); - }); - - }); - - describe('set to an invalid JSON object', function() { - - beforeEach(function() { - setupWhereQueryTests.call(this, '{firstname}'); - }); - - it('should throw a \'Could not parse JSON\' error', function() { - expect(this.error.message).toEqual('Could not parse JSON: {firstname}'); - }); - - }); - - function setupWhereQueryTests(whereQueryVal) { - var $this = this; - this.w = null; - var mockModel = MockModel.get('items', { - where: function(_w) { - $this.w = _w; - return { - fetchAll: function() { - return new MockPromise(['items']); - } - } - } - }); - this.k.expose(mockModel); - this.mockResponse = new MockResponse(); - spyOn(this.mockResponse, 'send'); - try { - this.mockApp.getHandlers['/items'](new MockRequest({ - query: { - where: whereQueryVal - } - }), this.mockResponse); - } catch (err) { - this.error = err; - } - } - - }); - - describe('with a \'load\' query param', function() { - - beforeEach(function() { - this.mockFetchAllFn = function() {}; - spyOn(this, 'mockFetchAllFn').and.returnValue(new MockPromise()); - this.mockModel = MockModel.get('items', { - fetchAll: this.mockFetchAllFn - }); - this.k.expose(this.mockModel); - this.mockApp.getHandlers['/items']( - new MockRequest({ - query: { - load: 'orders,companies' - } - }), - new MockResponse() - ); - }); - - it('should call load and pass an array of relation objects', function() { - expect(this.mockFetchAllFn).toHaveBeenCalled(); - expect(this.mockFetchAllFn.calls.argsFor(0)[0].withRelated) - .toEqual(['orders','companies']); - }); - - }); - - describe('with a before hook', function() { - - describe('that runs without executing any code', function() { - - hookExecTest('before', 'GetItems', '/items'); - - it('should pass model argument to the hook', function() { - expect(this.hookFn.calls.argsFor(0)[2]) - .toBe(this.mockModel.modelInstances[0]); - }); - - it('should call fetchAll', function() { - expect(this.mockFetchAll).toHaveBeenCalled(); - }); - - }); - - describe('that throws an error', function() { - hookErrorTest('before', 'GetItems', '/items'); - }); - - describe('that sends a response', function() { - - beforeEach(function() { - setupHook.call( - this, 'before', 'GetItems', '/items', - function(req, res) { res.send(true); } - ); - }); - - it('should not call fetchAll', function() { - expect(this.mockFetchAll).not.toHaveBeenCalled(); - }); - - }); - - describe('that returns a promise', function() { - - hookPromiseTest('before', 'GetItems', '/items'); - - it('should not call fetchAll', function() { - expect(this.mockFetchAll).not.toHaveBeenCalled(); - }); - - }); - - }); - - describe('with an after hook', function() { - - describe('that runs without executing any code', function() { - - hookExecTest('after', 'GetItems', '/items'); - - it('should pass the result of fetchAll to the hook', function() { - expect(this.hookFn.calls.argsFor(0)[2]) - .toBe(this.mockFetchAllResult); - }); - - }); - - describe('that throws an error', function() { - hookErrorTest('after', 'GetItems', '/items', true); - }); - - describe('that sends a response', function() { - singleResponseHookTest('after', 'GetItems', '/items'); - }); - - describe('that returns a promise', function() { - hookPromiseTest('after', 'GetItems', '/items'); - }); - - }); - -}); \ No newline at end of file diff --git a/test/get-relation-spec.js b/test/get-relation-spec.js deleted file mode 100644 index 7b55fb7..0000000 --- a/test/get-relation-spec.js +++ /dev/null @@ -1,138 +0,0 @@ -describe('GET request for a relation', function() { - - beforeEach(function() { - this.mockApp = new MockApp(); - this.k = requireKalamata()(this.mockApp); - }); - - describe('for a model that exists', function() { - - beforeEach(function() { - var $this = this; - this.mockCollection = {}; - this.mockModel = MockModel.get('items', { - related: function() { - return new MockPromise([$this.mockCollection]); - } - }); - this.k.expose(this.mockModel); - this.mockResponse = new MockResponse(); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.getHandlers['/items/:identifier/:relation']; - fn(new MockRequest(), this.mockResponse); - }); - - it('should respond with a collection of related models', function() { - expect(this.mockResponse.send).toHaveBeenCalled(); - expect(this.mockResponse.send.calls.argsFor(0)[0]) - .toBe(this.mockCollection); - }); - - }); - - describe('for a model that does not exist', function() { - - beforeEach(function() { - var $this = this; - this.mockNextFn = function() {}; - spyOn(this, 'mockNextFn'); - this.mockModel = MockModel.get('items', { - fetch: function() { - return new MockPromise([null]); - } - }); - this.k.expose(this.mockModel); - this.mockResponse = new MockResponse(); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.getHandlers['/items/:identifier/:relation']; - fn( - new MockRequest({ - params: { identifier: '1' }, - method: 'GET', - url: 'mock.com/items/1/things' - }), - this.mockResponse, - this.mockNextFn - ); - }); - - it('should call next with a not found error', function() { - expect(this.mockNextFn).toHaveBeenCalled(); - expect(this.mockNextFn.calls.argsFor(0)[0].message) - .toEqual('GET mock.com/items/1/things failed: id = 1 not found'); - }); - - it('should not send a response', function() { - expect(this.mockResponse.send).not.toHaveBeenCalled(); - }); - - }); - - describe('with a before hook', function() { - - describe('that runs without executing any code', function() { - - beforeEach(function() { - var $this = this; - this.hookFn = function() {} - this.k.expose(MockModel.get('items')); - this.k.expose(MockModel.get('things')); - this.k.beforeGetRelatedThings(function() { - $this.hookFn(); - }); - this.mockResponse = new MockResponse(); - spyOn(this.mockResponse, 'send'); - spyOn(this, 'hookFn'); - var fn = this.mockApp.getHandlers['/items/:identifier/:relation']; - fn(new MockRequest({ - params: { identifier: '1', relation: 'things' } - }), this.mockResponse); - }); - - it('should call the hook', function() { - expect(this.hookFn).toHaveBeenCalled(); - }); - - it('should send a response', function() { - expect(this.mockResponse.send).toHaveBeenCalled(); - }); - - }); - - describe('that throws an error', function() { - - beforeEach(function() { - var $this = this; - this.mockNextFn = function() {}; - this.hookFn = function() { - $this.mockError = new Error('mock error'); - throw $this.mockError; - } - this.k.expose(MockModel.get('items')); - this.k.expose(MockModel.get('things')); - this.k.beforeGetRelatedThings(function() { - $this.hookFn(); - }); - this.mockResponse = new MockResponse(); - spyOn(this.mockResponse, 'send'); - spyOn(this, 'mockNextFn'); - var fn = this.mockApp.getHandlers['/items/:identifier/:relation']; - try { - fn(new MockRequest({ - params: { identifier: '1', relation: 'things' } - }), this.mockResponse, this.mockNextFn); - } catch(err) { - this.error = err; - } - }); - - it('should throw an error', function() { - expect(this.error).toBeDefined(); - expect(this.error).toBe(this.mockError); - }); - - }); - - }); - -}); \ No newline at end of file diff --git a/test/get-spec.js b/test/get-spec.js deleted file mode 100644 index 9750155..0000000 --- a/test/get-spec.js +++ /dev/null @@ -1,202 +0,0 @@ -describe('GET request for single item', function() { - - beforeEach(function() { - this.mockApp = new MockApp(); - this.k = requireKalamata()(this.mockApp); - }); - - describe('with an identifier for an item that exists', function() { - - beforeEach(function() { - var $this = this; - this.mockFetchedModel = { name: 'mock' }; - this.mockModel = MockModel.get('items', { - fetch: function() { - return new MockPromise([$this.mockFetchedModel]); - } - }); - this.k.expose(this.mockModel); - this.mockResponse = new MockResponse(); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.getHandlers['/items/:identifier'] - fn(new MockRequest(), this.mockResponse); - }); - - it('should instantiate a model instance', function() { - expect(this.mockModel.modelInstances.length).toEqual(1); - }); - - it('should respond with the fetched model', function() { - expect(this.mockResponse.send.calls.argsFor(0)[0]) - .toEqual(this.mockFetchedModel); - }); - - }); - - describe('with a \'load\' query param', function() { - - beforeEach(function() { - this.mockFetchFn = function() {}; - spyOn(this, 'mockFetchFn').and.returnValue(new MockPromise()); - this.mockModel = MockModel.get('items', { - fetch: this.mockFetchFn - }); - this.k.expose(this.mockModel); - this.mockApp.getHandlers['/items/:identifier']( - new MockRequest({ - query: { - load: 'users,things' - } - }), - new MockResponse() - ); - }); - - it('should call load and pass an array of relations', function() { - expect(this.mockFetchFn).toHaveBeenCalled(); - expect(this.mockFetchFn.calls.argsFor(0)[0].withRelated) - .toEqual(['users','things']); - }); - - }); - - describe('with an identifier for an item that does not exist', function() { - - beforeEach(function() { - var $this = this; - this.p = new MockPromise(); - var mockModel = MockModel.get('items', { - fetch: function() { - return new MockPromise([null], $this.p); - } - }); - this.mockNextFn = function() {}; - spyOn(this, 'mockNextFn'); - this.k.expose(mockModel); - var fn = this.mockApp.getHandlers['/items/:identifier']( - new MockRequest({ - params: { identifier: '1' }, - method: 'GET', - url: 'mock.com/items/1' - }), - new MockResponse(), - this.mockNextFn - ); - }); - - it('should call next with a not found error', function() { - expect(this.mockNextFn).toHaveBeenCalled(); - expect(this.mockNextFn.calls.argsFor(0)[0].message) - .toEqual('GET mock.com/items/1 failed: id = 1 not found'); - }); - - }); - - describe('when the call to fetch() throws an error', function() { - - beforeEach(function() { - var $this = this; - this.mockErr = new Error('mock error'); - var mockModel = MockModel.get('items', { - fetch: function() { - return new MockFailPromise($this.mockErr); - } - }); - this.mockNextFn = function() {}; - spyOn(this, 'mockNextFn'); - this.k.expose(mockModel); - var fn = this.mockApp.getHandlers['/items/:identifier']( - new MockRequest(), new MockResponse(), this.mockNextFn - ); - }); - - it('should call next with the error', function() { - expect(this.mockNextFn).toHaveBeenCalled(); - expect(this.mockNextFn.calls.argsFor(0)[0]) - .toBe(this.mockErr); - }); - - }); - - describe('with a before hook', function() { - - describe('that runs without executing any code', function() { - - hookExecTest('before', 'GetItem', '/items/:identifier'); - - it('should pass model argument to the hook', function() { - expect(this.hookFn.calls.argsFor(0)[2]) - .toBe(this.mockModel.modelInstances[0]); - }); - - it('should call fetch', function() { - expect(this.mockFetch).toHaveBeenCalled(); - }); - - }); - - describe('that throws an error', function() { - - hookErrorTest('before', 'GetItem', '/items/:identifier'); - - it('should not call fetch', function() { - expect(this.mockFetch).not.toHaveBeenCalled(); - }); - - }); - - describe('that sends a response', function() { - - beforeEach(function() { - setupHook.call( - this, 'before', 'GetItem', '/items/:identifier', - function(req, res) { res.send(true); } - ); - }); - - it('should not call fetch', function() { - expect(this.mockFetch).not.toHaveBeenCalled(); - }); - - }); - - describe('that returns a promise', function() { - - hookPromiseTest('before', 'GetItem', '/items/:identifier'); - - it('should not call fetch', function() { - expect(this.mockFetch).not.toHaveBeenCalled(); - }); - - }); - - }); - - describe('with an after hook', function() { - - describe('that runs without executing any code', function() { - - hookExecTest('after', 'GetItem', '/items/:identifier'); - - it('should pass the result of fetch to the hook', function() { - expect(this.hookFn.calls.argsFor(0)[2]) - .toBe(this.mockFetchResult); - }); - - }); - - describe('that throws an error', function() { - hookErrorTest('after', 'GetItem', '/items/:identifier', true); - }); - - describe('that sends a response', function() { - singleResponseHookTest('after', 'GetItem', '/items/:identifier'); - }); - - describe('that returns a promise', function() { - hookPromiseTest('after', 'GetItem', '/items/:identifier'); - }); - - }); - -}); \ No newline at end of file diff --git a/test/get.coffee b/test/get.coffee new file mode 100644 index 0000000..ea40091 --- /dev/null +++ b/test/get.coffee @@ -0,0 +1,85 @@ + +chai = require('chai') +should = chai.should() + +module.exports = (g)-> + + r = chai.request(g.baseurl) + + describe 'get routes', -> + + it 'must get all', () -> + r.get('/').then (res) -> + res.should.have.status(200) + res.should.be.json + res.body[0].name.should.eql 'gandalfek' + + it 'must get gandalf', () -> + r.get("/#{g.gandalfID}").then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.name.should.eql 'gandalfek' + + it 'must return gandalf with all tools (magicwand)', () -> + r.get("/#{g.gandalfID}?_load=tools").then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.tools.length.should.eql 2 + + it 'must return all users with all tools', () -> + r.get("/?_load=tools").then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.length.should.eql 2 + res.body[0].tools.length.should.eql 2 + res.body[0].tools[0].type.should.eql 'supermagicwand' + + it 'must list 2nd page of users', () -> + r.get('/?_page=2&_pagesize=1').then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.length.should.eql 1 + res.body[0].name.should.eql 'saruman' + res.headers['x-total-count'].should.eql '2' + + it 'must list 2nd page of users but only names', () -> + r.get('/?_page=2&_pagesize=1&_attrs=name').then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.length.should.eql 1 + Object.keys(res.body[0]).length.should.eql 1 + res.body[0].name.should.eql 'saruman' + + it 'must list 2nd page of gandalf thigs', () -> + r.get("/#{g.gandalfID}/tools/?_page=2&_pagesize=1").then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.length.should.eql 1 + res.body[0].type.should.eql 'hat' + res.headers['x-total-count'].should.eql '2' + + it 'must list users sorted according name', () -> + r.get("/?_sortCol=name&_sortOrder=DESC").then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.length.should.eql 2 + res.body[0].name.should.eql 'saruman' + res.body[1].name.should.eql 'gandalfek' + + it 'must list gandalf thigs', () -> + r.get("/#{g.gandalfID}/tools/?_sortCol=type&_sortOrder=ASC").then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.length.should.eql 2 + res.body[0].type.should.eql 'hat' + res.body[1].type.should.eql 'supermagicwand' + + it 'must list gandalf thig types and ids ONLY', () -> + r.get("/#{g.gandalfID}/tools/?_sortCol=type&_sortOrder=ASC&_attrs=type,id") + .then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.length.should.eql 2 + Object.keys(res.body[0]).length.should.eql 2 # type,id + res.body[0].type.should.eql 'hat' + res.body[1].type.should.eql 'supermagicwand' diff --git a/test/helpers/helpers.js b/test/helpers/helpers.js deleted file mode 100644 index 9f6174b..0000000 --- a/test/helpers/helpers.js +++ /dev/null @@ -1,13 +0,0 @@ - -global.requireKalamata = function() { - deleteFromRequireCache('kalamata/src/index.js'); - return require('../../src/index.js'); -} - -global.deleteFromRequireCache = function(filePath) { - for(var n in require.cache) { - if(n.indexOf(filePath) != -1) { - delete require.cache[n]; - } - } -} diff --git a/test/helpers/hook-test-helpers.js b/test/helpers/hook-test-helpers.js deleted file mode 100644 index 432efcb..0000000 --- a/test/helpers/hook-test-helpers.js +++ /dev/null @@ -1,145 +0,0 @@ -global.singleResponseHookTest = function(prefix, postfix, endpoint, fn) { - - beforeEach(function() { - setupHook.call(this, prefix, postfix, endpoint, function(req, res) { - res.send(true); - }); - }); - - it('should only attempt to send the response once', function() { - expect(this.mockRes.send.calls.count()).toEqual(1); - expect(this.mockRes.send.calls.argsFor(0)[0]).toEqual(true); - }); - -}; - -global.hookExecTest = function(prefix, postfix, endpoint) { - - beforeEach(function() { - setupHook.call( - this, prefix, postfix, endpoint, function() {}); - }); - - it('should pass request and response arguments to the hook', - function() { - expect(this.hookFn).toHaveBeenCalled(); - expect(this.hookFn.calls.argsFor(0)[0]).toBe(this.mockReq); - expect(this.hookFn.calls.argsFor(0)[1]).toBe(this.mockRes); - }); - - it('should not throw and error', function() { - expect(this.error).toBeUndefined(); - }); - -}; - -global.hookErrorTest = function(prefix, postfix, endpoint, fromPromise) { - - beforeEach(function() { - setupHook.call( - this, prefix, postfix, endpoint, - function() { - throw new Error('mock hook error'); - } - ); - }); - - if(fromPromise) { - - it('should call next with the error', function() { - expect(this.mockNextFn).toHaveBeenCalled(); - }); - - } else { - - it('should throw an error', function() { - expect(this.error).toBeDefined(); - expect(this.error.message).toEqual('mock hook error'); - }); - - } - -} - -global.hookPromiseTest = function(prefix, postfix, endpoint) { - - beforeEach(function() { - var mockPromise = this.mockPromise = new MockPromise(); - spyOn(this.mockPromise, 'then'); - setupHook.call( - this, prefix, postfix, endpoint, - function(req, res) { - return mockPromise; - } - ); - }); - - it('should execute the promise callback', function() { - expect(this.mockPromise.then).toHaveBeenCalled() - }); - -} - -global.setupHook = function(prefix, postfix, endpoint, fn) { - this.hookFn = fn; - this.hookFnName = prefix + postfix; - var mockFetchAllResult = - this.mockFetchAllResult = { - toJSON: function() { - return [new (MockModel.get('items'))(), - new (MockModel.get('items'))(), - new (MockModel.get('items'))()]; - } - }; - var mockFetchResult = this.mockFetchResult = new (MockModel.get('items'))(); - var mockSaveResult = this.mockSaveResult = new (MockModel.get('items'))(); - this.mockFetchAll = function() { - return new MockPromise([mockFetchAllResult]); - }; - this.mockFetch = function() { - return new MockPromise([mockFetchResult]); - }; - this.mockSave = function() { - return new MockPromise([mockSaveResult]); - }; - spyOn(this, 'hookFn').and.callThrough(); - spyOn(this, 'mockFetchAll').and.callThrough(); - spyOn(this, 'mockFetch').and.callThrough(); - spyOn(this, 'mockSave').and.callThrough(); - spyOn(this.mockFetchResult, 'save').and.returnValue(this.mockFetchResult); - spyOn(this.mockFetchResult, 'destroy').and.returnValue(this.mockFetchResult); - this.mockModel = MockModel.get('items', { - fetchAll: this.mockFetchAll, - fetch: this.mockFetch, - save: this.mockSave - }); - this.mockRelatedModel = MockModel.get('things'); - this.k.expose(this.mockModel); - this.k.expose(this.mockRelatedModel); - this.k[this.hookFnName](this.hookFn); - this.mockReq = new MockRequest({ - params: { relation: 'things' } - }); - this.mockRes = new MockResponse(); - this.mockNextFn = function() {}; - spyOn(this, 'mockNextFn'); - spyOn(this.mockRes, 'send').and.callThrough(); - try { - this.mockApp[mockHandlerIndex[postfix]+'Handlers'][endpoint]( - this.mockReq, - this.mockRes, - this.mockNextFn - ); - } catch(err) { - this.error = err; - } -}; - -var mockHandlerIndex = { - 'GetItems': 'get', - 'GetItem': 'get', - 'CreateItem': 'post', - 'UpdateItem': 'put', - 'DeleteItem': 'delete', - 'GetThings': 'get' -}; \ No newline at end of file diff --git a/test/helpers/mock-app-helpers.js b/test/helpers/mock-app-helpers.js deleted file mode 100644 index 3bdbc14..0000000 --- a/test/helpers/mock-app-helpers.js +++ /dev/null @@ -1,29 +0,0 @@ - -global.MockApp = function() { - - function appMock() { - this.getHandlers = {}; - this.postHandlers = {}; - this.putHandlers = {}; - this.deleteHandlers = {}; - } - - appMock.prototype.use = function() {}; - - appMock.prototype.get = getMockListener('get'); - - appMock.prototype.post = getMockListener('post'); - - appMock.prototype.put = getMockListener('put'); - - appMock.prototype.delete = getMockListener('delete'); - - function getMockListener(type) { - return function(path, fn) { - this[type + 'Handlers'][path] = fn; - }; - } - - return new appMock(); - -}; diff --git a/test/helpers/mock-collection-helpers.js b/test/helpers/mock-collection-helpers.js deleted file mode 100644 index 9f04fe9..0000000 --- a/test/helpers/mock-collection-helpers.js +++ /dev/null @@ -1,6 +0,0 @@ - -global.MockCollection = { - - create: function() {} - -}; \ No newline at end of file diff --git a/test/helpers/mock-model-helpers.js b/test/helpers/mock-model-helpers.js deleted file mode 100644 index 8ce825c..0000000 --- a/test/helpers/mock-model-helpers.js +++ /dev/null @@ -1,63 +0,0 @@ - -global.MockModel = { - - get: function(tableName, modelMocks) { - - if(!modelMocks) modelMocks = {}; - - var m = function(attributes) { - m.modelInstances.push(this); - this.attributes = attributes; - }; - - m.modelInstances = []; - - m.forge = function() { - return { tableName: tableName }; - }; - - m.prototype.fetchAll = modelMocks.fetchAll || function(params) { - runWithRelatedFuncs(params); - return new MockPromise([MockCollection]); - }; - m.prototype.fetch = modelMocks.fetch || function(params) { - runWithRelatedFuncs(params); - return new MockPromise([this]); - }; - m.prototype.where = modelMocks.where || function() { - return this; - }; - m.prototype.load = modelMocks.load || function() { - return new MockPromise([this]); - }; - m.prototype.related = modelMocks.related || function() { - return MockCollection; - }; - m.prototype.save = modelMocks.save || function() { - return new MockPromise([this]); - }; - m.prototype.destroy = modelMocks.destroy || function() { - return new MockPromise([this]); - }; - m.prototype.set = modelMocks.set || function() { - return this; - } - m.prototype.toJSON = modelMocks.toJSON || function() { - return { name: 'mock toJSON() result' }; - } - - return m; - } - -}; - -function runWithRelatedFuncs(params) { - if(params && params.withRelated) { - for(var i in params.withRelated) { - var r = params.withRelated[i]; - for(var j in r) { - r[j](); - } - } - } -} diff --git a/test/helpers/mock-promise-helpers.js b/test/helpers/mock-promise-helpers.js deleted file mode 100644 index 4e5f422..0000000 --- a/test/helpers/mock-promise-helpers.js +++ /dev/null @@ -1,43 +0,0 @@ - -global.MockPromise = function(args, nextPromise) { - this.args = args; - this.nextPromise = nextPromise; -}; - -MockPromise.prototype.then = function(fn) { - if(!this.nextPromise) this.nextPromise = new MockPromise(this.args); - if(!this.thrownError && fn) { - try { - var returnVal = fn.apply(null, this.args); - if(returnVal) { - if(returnVal instanceof MockPromise) { - this.nextPromise = returnVal; - } else { - this.nextPromise = new MockPromise([returnVal]); - } - } - } catch(thrownError) { - this.thrownError = thrownError; - this.nextPromise.thrownError = thrownError; - } - } else if(this.thrownError) { - this.nextPromise.thrownError = this.thrownError; - } - return this.nextPromise; -}; - -MockPromise.prototype.catch = function(nextFn) { - if(nextFn && this.thrownError) nextFn(this.thrownError); -} - -global.MockFailPromise = function(error) { - this.error = error || new Error('promise failed'); -}; - -MockFailPromise.prototype.then = function() { - return this; -} - -MockFailPromise.prototype.catch = function(nextFn) { - if(nextFn) nextFn(this.error); -} diff --git a/test/helpers/mock-request-helpers.js b/test/helpers/mock-request-helpers.js deleted file mode 100644 index de37041..0000000 --- a/test/helpers/mock-request-helpers.js +++ /dev/null @@ -1,9 +0,0 @@ - -global.MockRequest = function(reqMocks) { - if(!reqMocks) reqMocks = {}; - this.query = reqMocks.query || {}; - this.params = reqMocks.params || {}; - this.body = reqMocks.body || undefined; - this.method = reqMocks.method || undefined; - this.url = reqMocks.url || undefined; -}; diff --git a/test/helpers/mock-response-helpers.js b/test/helpers/mock-response-helpers.js deleted file mode 100644 index 27767f1..0000000 --- a/test/helpers/mock-response-helpers.js +++ /dev/null @@ -1,9 +0,0 @@ - -global.MockResponse = MockResponse = function() { - this.headersSent = false; -}; - -MockResponse.prototype.send = function() { - this.headersSent = true; -}; - diff --git a/test/initialization-spec.js b/test/initialization-spec.js deleted file mode 100644 index 48c275d..0000000 --- a/test/initialization-spec.js +++ /dev/null @@ -1,186 +0,0 @@ -describe('initializing', function() { - - beforeEach(function() { - this.mockApp = new MockApp(); - spyOn(this.mockApp, 'get'); - spyOn(this.mockApp, 'post'); - spyOn(this.mockApp, 'put'); - spyOn(this.mockApp, 'delete'); - }) - - describe('with no options', function() { - - beforeEach(function() { - this.k = requireKalamata()(this.mockApp); - this.k.expose(MockModel.get('items')); - }); - - runEndpointConfigTests('/items'); - - }); - - describe('with an apiRoot option', function() { - - beforeEach(function() { - this.k = requireKalamata()(this.mockApp, { apiRoot: 'api' }); - this.k.expose(MockModel.get('items')); - }); - - runEndpointConfigTests('/api/items'); - - }); - - describe('with an apiRoot option that has more than one path segment', - function() { - - beforeEach(function() { - this.k = requireKalamata()(this.mockApp, { apiRoot: 'api/v1' }); - this.k.expose(MockModel.get('items')); - }); - - runEndpointConfigTests('/api/v1/items'); - - }); - - describe('with invalid options', function() { - - beforeEach(function() { - this.k = requireKalamata()(this.mockApp); - try { - this.k.expose(MockModel.get('items'), { mocked: 'invalid' }); - } catch(err) { - this.error = err; - } - }); - - it('should throw an error', function() { - expect(this.error.message).toEqual('Invalid option: mocked'); - }); - - }); - - describe('with a plural table name', function() { - - beforeEach(function() { - this.k = requireKalamata()(this.mockApp); - this.k.expose(MockModel.get('items')); - }); - - it('should set hook functions based on table name', function() { - expect(this.k.beforeGetItems).toBeDefined(); - expect(this.k.beforeGetRelatedItems).toBeDefined(); - expect(this.k.beforeGetItem).toBeDefined(); - expect(this.k.beforeCreateItem).toBeDefined(); - expect(this.k.beforeUpdateItem).toBeDefined(); - expect(this.k.beforeDeleteItem).toBeDefined(); - expect(this.k.afterGetItems).toBeDefined(); - expect(this.k.afterGetRelatedItems).toBeDefined(); - expect(this.k.afterGetItem).toBeDefined(); - expect(this.k.afterCreateItem).toBeDefined(); - expect(this.k.afterUpdateItem).toBeDefined(); - expect(this.k.afterDeleteItem).toBeDefined(); - }); - - }); - - describe('with a non-plural table name', function() { - - beforeEach(function() { - this.k = requireKalamata()(this.mockApp); - this.k.expose(MockModel.get('people')); - }); - - it('should set hook functions based on table name', function() { - expect(this.k.beforeGetPeopleCollection).toBeDefined(); - expect(this.k.beforeGetRelatedPeopleCollection).toBeDefined(); - expect(this.k.beforeGetPeople).toBeDefined(); - expect(this.k.beforeCreatePeople).toBeDefined(); - expect(this.k.beforeUpdatePeople).toBeDefined(); - expect(this.k.beforeDeletePeople).toBeDefined(); - expect(this.k.afterGetPeopleCollection).toBeDefined(); - expect(this.k.afterGetRelatedPeopleCollection).toBeDefined(); - expect(this.k.afterGetPeople).toBeDefined(); - expect(this.k.afterCreatePeople).toBeDefined(); - expect(this.k.afterUpdatePeople).toBeDefined(); - expect(this.k.afterDeletePeople).toBeDefined(); - }); - - }); - - describe('with custom model and collection names', function() { - - beforeEach(function() { - this.k = requireKalamata()(this.mockApp); - this.k.expose(MockModel.get('people'), { - modelName: 'person', - collectionName: 'people' - }); - }); - - it('should set hook functions based on table name', function() { - expect(this.k.beforeGetPeople).toBeDefined(); - expect(this.k.beforeGetRelatedPeople).toBeDefined(); - expect(this.k.beforeGetPerson).toBeDefined(); - expect(this.k.beforeCreatePerson).toBeDefined(); - expect(this.k.beforeUpdatePerson).toBeDefined(); - expect(this.k.beforeDeletePerson).toBeDefined(); - expect(this.k.afterGetPeople).toBeDefined(); - expect(this.k.afterGetRelatedPeople).toBeDefined(); - expect(this.k.afterGetPerson).toBeDefined(); - expect(this.k.afterCreatePerson).toBeDefined(); - expect(this.k.afterUpdatePerson).toBeDefined(); - expect(this.k.afterDeletePerson).toBeDefined(); - }); - - }); - - describe('with an apiRoot option that has a leading and trailing slash', - function() { - - beforeEach(function() { - this.k = requireKalamata()(this.mockApp, { apiRoot: '/api/' }); - this.k.expose(MockModel.get('items')); - }); - - runEndpointConfigTests('/api/items'); - - }); - - function runEndpointConfigTests(endpointPath) { - - it('should configure get endpoint for collection', function() { - expect(this.mockApp.get.calls.argsFor(0)[0]).toEqual(endpointPath); - }); - - it('should configure get endpoint for single item', function() { - expect(this.mockApp.get.calls.argsFor(1)[0]) - .toEqual(endpointPath + '/:identifier'); - }); - - it('should configure get endpoint for a related collection', function() { - expect(this.mockApp.get.calls.argsFor(2)[0]) - .toEqual(endpointPath + '/:identifier/:relation'); - }); - - it('should configure post endpoint for collection', function() { - expect(this.mockApp.post.calls.argsFor(0)[0]).toEqual(endpointPath); - }); - - it('should configure post endpoint for related collection', function() { - expect(this.mockApp.post.calls.argsFor(1)[0]) - .toEqual(endpointPath + '/:identifier/:relation'); - }); - - it('should configure put endpoint for single item', function() { - expect(this.mockApp.put.calls.argsFor(0)[0]) - .toEqual(endpointPath + '/:identifier'); - }); - - it('should configure delete endpoint for single item', function() { - expect(this.mockApp.delete.calls.argsFor(0)[0]) - .toEqual(endpointPath + '/:identifier'); - }); - - } - -}); \ No newline at end of file diff --git a/test/main.js b/test/main.js new file mode 100644 index 0000000..7df20fa --- /dev/null +++ b/test/main.js @@ -0,0 +1,76 @@ +const bodyParser = require('body-parser') +const express = require('express') +const chai = require('chai') +const chaiHttp = require('chai-http') +chai.use(chaiHttp) +const should = chai.should() + +const db = require('./db') +const Kalamata = require('../src/index') + +process.env.SERVER_SECRET = 'fhdsakjhfkjal' +port = process.env.PORT || 3333 +const g = {} + +// entry ... +describe('app', (suite) => { + + g.app = app = express() + app.use(bodyParser.json()) + g.db = db + + const k = Kalamata(db.models.User, { + createError: (message, status = 400) => { + return new Error({status: status, message: message}) + } + }) + k.init_app(app) // create the REST routes + app.use((err, req, res, next) => { + if (err.message === 'EmptyResponse') { + return res.status(404).send(err) + } + const statusCode = parseInt(err.message) + res.status(isNaN(statusCode) ? 400 : statusCode).send(err) + console.log(err) + }) + + before((done) => { + g.db.migrate.latest() + .then(() => { + // init server + g.server = app.listen(port, (err) => { + if (err) return done(err) + done() + }) + }) + .catch(done) + }) + + after((done) => { + g.server.close() + done() + }) + + it('should exist', (done) => { + should.exist(g.app) + should.exist(k.sorting_q) + should.exist(k.list) + should.exist(k.create_rel) + done() + }) + + // run the rest of tests + g.baseurl = `http://localhost:${port}` + + const submodules = [ + './post', + './put', + './get', + './del' + ] + submodules.forEach((i) => { + const SubMod = require(i) + SubMod(g) + }) + +}) diff --git a/test/migrations/create_tables.coffee b/test/migrations/create_tables.coffee new file mode 100644 index 0000000..f0f5084 --- /dev/null +++ b/test/migrations/create_tables.coffee @@ -0,0 +1,22 @@ +exports.up = (knex, Promise) -> + Promise.all [ + knex.schema.createTable 'users', (table) -> + table.increments() + table.text 'name' + table.dateTime('created_at').notNullable().defaultTo knex.fn.now() + table.dateTime('updated_at').notNullable().defaultTo knex.fn.now() + , + knex.schema.createTable 'things', (table) -> + table.increments() + table.integer('user_id').references('id').inTable 'users' + table.text 'type' + table.boolean 'deleted' + table.dateTime('created_at').notNullable().defaultTo knex.fn.now() + table.dateTime('updated_at').notNullable().defaultTo knex.fn.now() + ] + +exports.down = (knex, Promise) -> + Promise.all [ + knex.schema.dropTableIfExists('users') + knex.schema.dropTableIfExists('things') + ] diff --git a/test/post-relation-spec.js b/test/post-relation-spec.js deleted file mode 100644 index 6c4afa5..0000000 --- a/test/post-relation-spec.js +++ /dev/null @@ -1,271 +0,0 @@ -describe('POST request to create a relation', function() { - - beforeEach(function() { - var $this = this; - - this.mockApp = new MockApp(); - this.k = requireKalamata()(this.mockApp); - - this.mockCollection = { - create: function() {} - }; - this.mockModel = MockModel.get('items', { - related: function() { - return $this.mockCollection - } - }); - this.mockRelModel = MockModel.get('things'); - this.mockResponse = new MockResponse(); - - this.mockRequestOptions = { - body: { id: 1 }, - params: { relation: 'things' } - } - }); - - describe('to an existing model', function() { - - beforeEach(function() { - this.k.expose(this.mockModel); - this.k.expose(this.mockRelModel); - spyOn(this.mockCollection, 'create'); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.postHandlers['/items/:identifier/:relation']; - fn(new MockRequest(this.mockRequestOptions), this.mockResponse); - }); - - it('should call create on the related collection ', function() { - expect(this.mockCollection.create).toHaveBeenCalled(); - }); - - it('should pass the existing related model to the create call', - function() { - expect(this.mockCollection.create.calls.argsFor(0)[0]) - .toBe(this.mockRelModel.modelInstances[0]); - }); - - it('should send a response', function() { - expect(this.mockResponse.send).toHaveBeenCalled(); - }); - - }); - - describe('to an new model', function() { - - beforeEach(function() { - var $this = this; - this.mockFetchPromise = new MockPromise([{ - related: function() {} - }]); - this.mockModel = MockModel.get('items', { - fetch: function() { - return $this.mockFetchPromise; - } - }); - this.k.expose(this.mockModel); - this.k.expose(this.mockRelModel); - this.mockBody = {}; - spyOn(this.mockCollection, 'create'); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.postHandlers['/items/:identifier/:relation']; - this.mockRequestOptions.body = this.mockBody; - fn(new MockRequest(this.mockRequestOptions), this.mockResponse); - }); - - it('should not call create on the related collection ', function() { - expect(this.mockCollection.create).not.toHaveBeenCalled(); - }); - - it('should not send a response', function() { - expect(this.mockResponse.send).not.toHaveBeenCalled(); - }); - - it('should throw an error', function() { - expect(this.mockFetchPromise.thrownError.message) - .toBe('Create relationship failed: ' + - 'id property not provided'); - }); - - }); - - describe('to an existing model that is not found', function() { - - beforeEach(function() { - var $this = this; - this.mockFetchPromise = new MockPromise([null]); - this.mockRelModel = MockModel.get('things', { - fetch: function() { - return $this.mockFetchPromise; - } - }); - this.k.expose(this.mockModel); - this.k.expose(this.mockRelModel); - spyOn(this.mockCollection, 'create'); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.postHandlers['/items/:identifier/:relation']; - fn(new MockRequest(this.mockRequestOptions), this.mockResponse); - }); - - it('should not call create on the related collection', function() { - expect(this.mockCollection.create).not.toHaveBeenCalled(); - }); - - it('should throw an error', function() { - expect(this.mockFetchPromise.thrownError.message) - .toBe('Create relationship failed: ' + - 'Could not find things model {"id":1}'); - }); - - it('should not send a response', function() { - expect(this.mockResponse.send).not.toHaveBeenCalled(); - }); - - }); - - - describe('with a before hook', function() { - - describe('that returns nothing', function() { - - beforeEach(function() { - var $this = this; - this.k.expose(this.mockModel); - this.k.expose(this.mockRelModel); - this.relHookCalled; - this.k.beforeRelateThing(function() { - $this.relHookCalled = true; - }); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.postHandlers['/items/:identifier/:relation']; - fn(new MockRequest(this.mockRequestOptions), this.mockResponse); - }); - - it('should call the hook function', function() { - expect(this.relHookCalled).toBeTruthy(); - }); - - it('should send a response', function() { - expect(this.mockResponse.send).toHaveBeenCalled(); - }); - - }); - - describe('that throws an error', function() { - - beforeEach(function() { - var $this = this; - this.k.expose(this.mockModel); - this.k.expose(this.mockRelModel); - this.mockErr; - this.k.beforeRelateThing(function() { - throw new Error('mock error'); - }); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.postHandlers['/items/:identifier/:relation']; - try { - fn(new MockRequest(this.mockRequestOptions), this.mockResponse); - } catch(err) { - this.mockErr = err; - } - }); - - it('should call the hook function', function() { - expect(this.mockErr.message).toBe('mock error'); - }); - - it('should not send a response', function() { - expect(this.mockResponse.send).not.toHaveBeenCalled(); - }); - - }); - - }); - - describe('with a before hook on the base model', function() { - - describe('that returns nothing', function() { - - beforeEach(function() { - var $this = this; - this.k.expose(this.mockModel); - this.k.expose(this.mockRelModel); - this.relHookCalled; - this.k.beforeRelateItem(function() { - $this.relHookCalled = true; - }); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.postHandlers['/items/:identifier/:relation']; - fn(new MockRequest(this.mockRequestOptions), this.mockResponse); - }); - - it('should call the hook function', function() { - expect(this.relHookCalled).toBeTruthy(); - }); - - it('should send a response', function() { - expect(this.mockResponse.send).toHaveBeenCalled(); - }); - - }); - - }); - - describe('with an after hook', function() { - - describe('that returns nothing', function() { - - beforeEach(function() { - var $this = this; - this.k.expose(this.mockModel); - this.k.expose(this.mockRelModel); - this.relHookCalled; - this.k.afterRelateThing(function() { - $this.relHookCalled = true; - }); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.postHandlers['/items/:identifier/:relation']; - fn(new MockRequest(this.mockRequestOptions), this.mockResponse); - }); - - it('should call the hook function', function() { - expect(this.relHookCalled).toBeTruthy(); - }); - - it('should send a response', function() { - expect(this.mockResponse.send).toHaveBeenCalled(); - }); - - }); - - }); - - describe('with an after hook on the base model', function() { - - describe('that returns nothing', function() { - - beforeEach(function() { - var $this = this; - this.k.expose(this.mockModel); - this.k.expose(this.mockRelModel); - this.relHookCalled; - this.k.afterRelateItem(function() { - $this.relHookCalled = true; - }); - spyOn(this.mockResponse, 'send'); - var fn = this.mockApp.postHandlers['/items/:identifier/:relation']; - fn(new MockRequest(this.mockRequestOptions), this.mockResponse); - }); - - it('should call the hook function', function() { - expect(this.relHookCalled).toBeTruthy(); - }); - - it('should send a response', function() { - expect(this.mockResponse.send).toHaveBeenCalled(); - }); - - }); - - }); - -}); \ No newline at end of file diff --git a/test/post-spec.js b/test/post-spec.js deleted file mode 100644 index ef61585..0000000 --- a/test/post-spec.js +++ /dev/null @@ -1,156 +0,0 @@ -describe('POST request to create a new item', function() { - - beforeEach(function() { - this.mockApp = new MockApp(); - this.k = requireKalamata()(this.mockApp); - }); - - describe('and save succeeded', function() { - - beforeEach(function() { - var $this = this; - this.mockDataResponse = { mocked: true }; - this.k.expose(MockModel.get('items', { - save: function() { - return new MockPromise([{ - toJSON: function() { - return $this.mockDataResponse; - } - }]); - } - })); - this.mockResponse = new MockResponse(); - spyOn(this.mockResponse, 'send'); - this.mockApp.postHandlers['/items']( - new MockRequest(), - this.mockResponse - ); - }); - - it('should response with the identifier of the new item', function() { - expect(this.mockResponse.send.calls.argsFor(0)[0]) - .toEqual(this.mockDataResponse); - }); - - }); - - describe('and save failed', function() { - - beforeEach(function() { - this.mockBody = { name: 'mock' }; - this.mockNextFn = function() {}; - spyOn(this, 'mockNextFn'); - this.mockRequest = new MockRequest({ - body: this.mockBody - }); - this.k.expose(MockModel.get('items', { - save: function() { - return new MockFailPromise(); - } - })); - this.mockApp.postHandlers['/items']( - new MockRequest(), - new MockResponse(), - this.mockNextFn - ); - }); - - it('should call next with a Create Item failed error', function() { - expect(this.mockNextFn).toHaveBeenCalled(); - expect(this.mockNextFn.calls.argsFor(0)[0]).toEqual(new Error('Create Item ' + - JSON.stringify(this.mockBody) + ' failed')); - }); - - }); - - describe('with a before hook', function() { - - describe('that runs without executing any code', function() { - - hookExecTest('before', 'CreateItem', '/items'); - - it('should pass model argument to the hook', function() { - expect(this.hookFn.calls.argsFor(0)[2]) - .toBe(this.mockModel.modelInstances[0]); - }); - - it('should call save', function() { - expect(this.mockSave).toHaveBeenCalled(); - }); - - }); - - describe('that throws an error', function() { - - hookErrorTest('before', 'CreateItem', '/items'); - - it('should not call save', function() { - expect(this.mockSave).not.toHaveBeenCalled(); - }); - - }); - - describe('that sends a response', function() { - - beforeEach(function() { - setupHook.call( - this, 'before', 'CreateItem', '/items', - function(req, res) { - res.send(true); - } - ); - }); - - it('should not call save', function() { - expect(this.mockSave).not.toHaveBeenCalled(); - }); - - }); - - describe('that returns a promise', function() { - - hookPromiseTest('before', 'CreateItem', '/items'); - - it('should not call save', function() { - expect(this.mockSave).not.toHaveBeenCalled(); - }); - - }); - - }); - - describe('with an after hook', function() { - - describe('that runs without executing any code', function() { - - hookExecTest('after', 'CreateItem', '/items'); - - beforeEach(function() { - setupHook.call( - this, 'after', 'CreateItem', '/items', - function() {} - ); - }); - - it('should pass the result of save to the hook', function() { - expect(this.hookFn.calls.argsFor(0)[2]) - .toBe(this.mockSaveResult); - }); - - }); - - describe('that throws an error', function() { - hookErrorTest('after', 'CreateItem', '/items', true); - }); - - describe('that sends a response', function() { - singleResponseHookTest('after', 'CreateItem', '/items'); - }); - - describe('that returns a promise', function() { - hookPromiseTest('after', 'CreateItem', '/items'); - }); - - }); - -}); \ No newline at end of file diff --git a/test/post.coffee b/test/post.coffee new file mode 100644 index 0000000..02aaaa9 --- /dev/null +++ b/test/post.coffee @@ -0,0 +1,47 @@ + +chai = require('chai') +should = chai.should() + +module.exports = (g)-> + + r = chai.request(g.baseurl) + + describe 'post routes', -> + + it 'must create user', () -> + r.post('/').send({ name: 'gandalf' }).then (res) -> + res.should.have.status(201) + res.should.be.json + res.body.name.should.eql 'gandalf' + g.gandalfID = res.body.id + + it 'must get gandalfs tools - []', () -> + r.get("/#{g.gandalfID}/tools").then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.should.eql [] + + it 'must add an magicwand and hat to gandalf', () -> + r.post("/#{g.gandalfID}/tools").send({ type: 'magicwand' }) + .then (res) -> + res.should.have.status(201) + res.should.be.json + res.body.type.should.eql 'magicwand' + g.magicwandid = res.body.id + return r.post("/#{g.gandalfID}/tools").send({ type: 'hat' }) + .then (res) -> + res.should.have.status(201) + res.should.be.json + res.body.type.should.eql 'hat' + + it 'add another user, saruman', () -> + r.post('/').send({ name: 'saruman' }) + .then (res) -> + res.should.have.status(201) + res.should.be.json + g.sarumanID = res.body.id + return r.post("/#{g.sarumanID}/tools").send({ type: 'fury' }) + .then (res) -> + res.should.have.status(201) + res.should.be.json + res.body.type.should.eql 'fury' diff --git a/test/put-spec.js b/test/put-spec.js deleted file mode 100644 index bff63f3..0000000 --- a/test/put-spec.js +++ /dev/null @@ -1,171 +0,0 @@ -describe('PUT request to update an item', function() { - - beforeEach(function() { - this.mockApp = new MockApp(); - this.k = requireKalamata()(this.mockApp); - }); - - describe('for an item that exists', function() { - - beforeEach(function() { - var $this = this; - this.mockRequest = new MockRequest({ - params: { identifier: '1' }, - body: { name: 'mock' } - }); - this.mockResponse = new MockResponse(); - this.mockFetchedModel = new (MockModel.get('items'))(); - this.mockModel = MockModel.get('items', { - fetch: function() { - return new MockPromise([$this.mockFetchedModel]); - } - }); - spyOn(this.mockFetchedModel, 'set'); - spyOn(this.mockFetchedModel, 'save'); - spyOn(this.mockResponse, 'send'); - this.k.expose(this.mockModel); - this.mockApp.putHandlers['/items/:identifier']( - this.mockRequest, - this.mockResponse - ); - }); - - it('should set attributes on the model that will be fetched', function() { - expect(this.mockModel.modelInstances[0].attributes) - .toEqual({ id : '1' }); - }); - - it('should set req.body properties on the model', function() { - expect(this.mockFetchedModel.set.calls.argsFor(0)[0].name) - .toEqual('mock'); - }); - - it('should call save() on the model', function() { - expect(this.mockFetchedModel.save).toHaveBeenCalled(); - }); - - it('should respond with the model converted to JSON', function() { - expect(this.mockResponse.send.calls.argsFor(0)[0].name) - .toEqual('mock toJSON() result'); - }); - - }); - - describe('for an item that does not exist', function() { - - beforeEach(function() { - var $this = this; - this.mockNextFn = function() {}; - spyOn(this, 'mockNextFn'); - this.p = new MockPromise(); - this.mockParams = { identifier: '1' }; - this.k.expose(MockModel.get('items', { - fetch: function() { - return $this.p; - } - })); - this.mockApp.putHandlers['/items/:identifier']( - new MockRequest({ - params: this.mockParams, - method: 'PUT', - url: 'mock.com/items/1' - }), - new MockResponse(), - this.mockNextFn - ); - }); - - it('should call next with a not found error', function() { - expect(this.mockNextFn).toHaveBeenCalled(); - expect(this.mockNextFn.calls.argsFor(0)[0].message) - .toEqual('PUT mock.com/items/1 failed: id = 1 not found'); - }); - - }); - - describe('with a before hook', function() { - - describe('that runs without executing any code', function() { - - hookExecTest('before', 'UpdateItem', '/items/:identifier'); - - it('should pass the fetch result argument to the hook', function() { - expect(this.hookFn.calls.argsFor(0)[2]) - .toBe(this.mockFetchResult); - }); - - it('should call save on the fetch result', function() { - expect(this.mockFetchResult.save).toHaveBeenCalled(); - }); - - }); - - describe('that throws an error', function() { - - hookErrorTest('before', 'UpdateItem', '/items/:identifier', true); - - it('should not call save on the fetch result', function() { - expect(this.mockFetchResult.save).not.toHaveBeenCalled(); - }); - - }); - - describe('that sends a response', function() { - - beforeEach(function() { - setupHook.call( - this, 'before', 'UpdateItem', '/items/:identifier', - function(req, res) { res.send(true); } - ); - }); - - it('should not call save on the fetch result', function() { - expect(this.mockFetchResult.save).not.toHaveBeenCalled(); - }); - - it('should not throw an error', function() { - expect(this.mockNextFn).not.toHaveBeenCalled(); - }); - - }); - - describe('that returns a promise', function() { - - hookPromiseTest('before', 'UpdateItem', '/items/:identifier'); - - it('should not call save on the fetch result', function() { - expect(this.mockFetchResult.save).not.toHaveBeenCalled(); - }); - - }); - - }); - - describe('with an after hook', function() { - - describe('that runs without executing any code', function() { - - hookExecTest('after', 'UpdateItem', '/items/:identifier'); - - it('should pass the fetch result to the hook', function() { - expect(this.hookFn.calls.argsFor(0)[2]) - .toBe(this.mockFetchResult); - }); - - }); - - describe('that throws an error', function() { - hookErrorTest('after', 'UpdateItem', '/items/:identifier', true); - }); - - describe('that sends a response', function() { - singleResponseHookTest('after', 'UpdateItem', '/items/:identifier'); - }); - - describe('that returns a promise', function() { - hookPromiseTest('after', 'UpdateItem', '/items/:identifier'); - }); - - }); - -}); \ No newline at end of file diff --git a/test/put.coffee b/test/put.coffee new file mode 100644 index 0000000..8b4718e --- /dev/null +++ b/test/put.coffee @@ -0,0 +1,31 @@ + +chai = require('chai') +should = chai.should() + +module.exports = (g)-> + + r = chai.request(g.baseurl) + + describe 'put routes', -> + + it 'must update user', () -> + r.put("/#{g.gandalfID}").send({ name: 'gandalfek' }).then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.name.should.eql 'gandalfek' + res.body.id.should.eql g.gandalfID + + it 'must change magicwand to supermagicwand', () -> + return r.put("/#{g.gandalfID}/tools?id=#{g.magicwandid}") + .send({ type: 'supermagicwand' }) + .then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.length.should.eql 1 + res.body[0].type.should.eql 'supermagicwand' + # verify that gandalf has now supermagicwand + return r.get("/#{g.gandalfID}?_load=tools") + .then (res) -> + res.should.have.status(200) + res.should.be.json + res.body.tools[0].type.should.eql 'supermagicwand'