├── .eslintrc ├── scripts ├── watch.sh ├── env.sh ├── build.sh └── test.sh ├── src ├── associations │ ├── index.js │ ├── associate.js │ ├── one-to-one.js │ └── one-to-many.js ├── crud-route-creation.integration.test.js ├── crud-scope.integration.test.js ├── crud-create.integration.test.js ├── crud-update.integration.test.js ├── crud-where.integration.test.js ├── error.js ├── utils.test.js ├── crud-include.integration.test.js ├── crud-list-limit-and-offset.integration.test.js ├── utils.js ├── index.js ├── get-config-for-method.js ├── crud-list-order.integration.test.js ├── crud.test.js ├── crud-destroy.integration.test.js ├── crud.js └── get-config-for-method.test.js ├── circle.yml ├── CONTRIBUTING ├── .babelrc ├── test ├── fixtures │ └── models │ │ ├── city.js │ │ ├── team.js │ │ └── player.js └── integration-setup.js ├── .gitignore ├── LICENSE ├── package.json └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "ava" 4 | ], 5 | "extends": [ 6 | "pichak", 7 | "plugin:ava/recommended" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /scripts/watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # strict mode http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | 6 | ./scripts/build.sh --watch 7 | -------------------------------------------------------------------------------- /src/associations/index.js: -------------------------------------------------------------------------------- 1 | import oneToOne from './one-to-one'; 2 | import oneToMany from './one-to-many'; 3 | import associate from './associate'; 4 | 5 | export { oneToOne, oneToMany, associate }; 6 | -------------------------------------------------------------------------------- /scripts/env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # strict mode http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | 6 | export SRC_DIR="./src" 7 | export OUT_DIR="./build" 8 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.9.0 4 | 5 | dependencies: 6 | pre: 7 | - npm prune 8 | post: 9 | - mkdir -p $CIRCLE_TEST_REPORTS/ava 10 | 11 | test: 12 | post: 13 | - npm run coverage 14 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | Commit Message 2 | =============== 3 | Please follow [this convention](http://karma-runner.github.io/1.0/dev/git-commit-msg.html) for git commit message. 4 | 5 | Lint 6 | ==== 7 | Please lint your code using `npm run lint` (also `npm run lint -- --fix` to auto-fix). 8 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # strict mode http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | 6 | 7 | source "scripts/env.sh" 8 | 9 | babel="./node_modules/.bin/babel" 10 | 11 | build () { 12 | $babel "$SRC_DIR" --out-dir "$OUT_DIR" $@ 13 | } 14 | 15 | build $@ 16 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "stage-1" 4 | ], 5 | "plugins": [ 6 | "transform-object-rest-spread", 7 | "transform-class-properties", 8 | "add-module-exports", 9 | "closure-elimination", 10 | "transform-decorators-legacy", 11 | "transform-es2015-modules-commonjs" 12 | ], 13 | "sourceMaps": "inline" 14 | } 15 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # strict mode http://redsymbol.net/articles/unofficial-bash-strict-mode/ 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | 6 | nyc=./node_modules/.bin/nyc 7 | ava=./node_modules/.bin/ava 8 | 9 | if [ ! -z ${CI:-} ]; then 10 | $nyc $ava --tap=${CI-false} | tap-xunit > $CIRCLE_TEST_REPORTS/ava/ava.xml 11 | else 12 | $nyc $ava 13 | fi 14 | 15 | -------------------------------------------------------------------------------- /test/fixtures/models/city.js: -------------------------------------------------------------------------------- 1 | export default (sequelize, DataTypes) => { 2 | return sequelize.define('City', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | primaryKey: true, 6 | autoIncrement: true, 7 | }, 8 | name: DataTypes.STRING, 9 | }, { 10 | classMethods: { 11 | associate: (models) => { 12 | models.City.hasMany(models.Team, { 13 | foreignKey: { name: 'cityId' }, 14 | }); 15 | }, 16 | }, 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /test/fixtures/models/team.js: -------------------------------------------------------------------------------- 1 | export default (sequelize, DataTypes) => { 2 | return sequelize.define('Team', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | primaryKey: true, 6 | autoIncrement: true, 7 | }, 8 | name: DataTypes.STRING, 9 | cityId: DataTypes.INTEGER, 10 | }, { 11 | classMethods: { 12 | associate: (models) => { 13 | models.Team.belongsTo(models.City, { 14 | foreignKey: { name: 'cityId' }, 15 | }); 16 | models.Team.hasMany(models.Player, { 17 | foreignKey: { name: 'teamId' }, 18 | }); 19 | }, 20 | }, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | #### joe made this: https://goel.io/joe 3 | 4 | #####=== Node ===##### 5 | 6 | # Logs 7 | logs 8 | *.log 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directory 31 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 32 | node_modules 33 | 34 | # Debug log from npm 35 | npm-debug.log 36 | 37 | # System 38 | .DS_Store 39 | 40 | coverage.lcov 41 | .nyc_output 42 | -------------------------------------------------------------------------------- /test/fixtures/models/player.js: -------------------------------------------------------------------------------- 1 | export default (sequelize, DataTypes) => { 2 | return sequelize.define('Player', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | primaryKey: true, 6 | autoIncrement: true, 7 | }, 8 | name: DataTypes.STRING, 9 | teamId: DataTypes.INTEGER, 10 | active: DataTypes.BOOLEAN, 11 | }, { 12 | classMethods: { 13 | associate: (models) => { 14 | models.Player.belongsTo(models.Team, { 15 | foreignKey: { name: 'teamId' }, 16 | }); 17 | }, 18 | }, 19 | scopes: { 20 | returnsOne: { 21 | where: { 22 | active: true, 23 | }, 24 | }, 25 | returnsNone: { 26 | where: { 27 | name: 'notaname', 28 | }, 29 | }, 30 | returnsAll: { 31 | where: { 32 | name: { 33 | $ne: 'notaname', 34 | }, 35 | }, 36 | }, 37 | }, 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /src/associations/associate.js: -------------------------------------------------------------------------------- 1 | import error from '../error'; 2 | import { getMethod } from '../utils'; 3 | 4 | let prefix; 5 | let defaultConfig; 6 | 7 | export default (server, a, b, names, options) => { 8 | prefix = options.prefix; 9 | defaultConfig = options.defaultConfig; 10 | 11 | server.route({ 12 | method: 'GET', 13 | path: `${prefix}/associate/${names.a.singular}/{aid}/${names.b.singular}/{bid}`, 14 | 15 | @error 16 | async handler(request, reply) { 17 | const instanceb = await b.findOne({ 18 | where: { 19 | [b.primaryKeyField]: request.params.bid, 20 | }, 21 | }); 22 | 23 | const instancea = await a.findOne({ 24 | where: { 25 | [a.primaryKeyField]: request.params.aid, 26 | }, 27 | }); 28 | 29 | const fn = getMethod(instancea, names.b, false, 'add') || 30 | getMethod(instancea, names.b, false, 'set'); 31 | await fn(instanceb); 32 | 33 | reply([instancea, instanceb]); 34 | }, 35 | 36 | config: defaultConfig, 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/crud-route-creation.integration.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'sinon-bluebird'; 3 | import setup from '../test/integration-setup.js'; 4 | 5 | const { modelNames } = setup(test); 6 | 7 | const confirmRoute = (t, { path, method }) => { 8 | const { server } = t.context; 9 | // there's only one connection, so just get the first table 10 | const routes = server.table()[0].table; 11 | 12 | t.truthy(routes.find((route) => { 13 | return route.path = path 14 | && route.method === method; 15 | })); 16 | }; 17 | 18 | modelNames.forEach(({ singular, plural }) => { 19 | test('get', confirmRoute, { path: `/${singular}/{id}`, method: 'get' }); 20 | test('list', confirmRoute, { path: `/${plural}/{id}`, method: 'get' }); 21 | test('scope', confirmRoute, { path: `/${plural}/{scope}`, method: 'get' }); 22 | test('create', confirmRoute, { path: `/${singular}`, method: 'post' }); 23 | test('destroy', confirmRoute, { path: `/${plural}`, method: 'delete' }); 24 | test('destroyScope', confirmRoute, { path: `/${plural}/{scope}`, method: 'delete' }); 25 | test('update', confirmRoute, { path: `/${singular}/{id}`, method: 'put' }); 26 | }); 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 The hapi-sequelize-crud Authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/crud-scope.integration.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'sinon-bluebird'; 3 | import setup from '../test/integration-setup.js'; 4 | 5 | const STATUS_OK = 200; 6 | const STATUS_NOT_FOUND = 404; 7 | const STATUS_BAD_REQUEST = 400; 8 | 9 | setup(test); 10 | 11 | test('/players/returnsOne', async (t) => { 12 | const { server, instances } = t.context; 13 | const { player1 } = instances; 14 | const url = '/players/returnsOne'; 15 | const method = 'GET'; 16 | 17 | const { result, statusCode } = await server.inject({ url, method }); 18 | t.is(statusCode, STATUS_OK); 19 | t.is(result.length, 1); 20 | t.truthy(result[0].id, player1.id); 21 | }); 22 | 23 | test('/players/returnsNone', async (t) => { 24 | const { server } = t.context; 25 | const url = '/players/returnsNone'; 26 | const method = 'GET'; 27 | 28 | const { statusCode } = await server.inject({ url, method }); 29 | t.is(statusCode, STATUS_NOT_FOUND); 30 | }); 31 | 32 | test('invalid scope /players/invalid', async (t) => { 33 | const { server } = t.context; 34 | // this doesn't exist in our fixtures 35 | const url = '/players/invalid'; 36 | const method = 'GET'; 37 | 38 | const { statusCode } = await server.inject({ url, method }); 39 | t.is(statusCode, STATUS_BAD_REQUEST); 40 | }); 41 | -------------------------------------------------------------------------------- /src/crud-create.integration.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'sinon-bluebird'; 3 | import setup from '../test/integration-setup.js'; 4 | 5 | const STATUS_OK = 200; 6 | const STATUS_NOT_FOUND = 404; 7 | const STATUS_BAD_REQUEST = 400; 8 | 9 | setup(test); 10 | 11 | test('where /player {name: "Chard"}', async (t) => { 12 | const { server, sequelize: { models: { Player } } } = t.context; 13 | const url = '/player'; 14 | const method = 'POST'; 15 | const payload = { name: 'Chard' }; 16 | 17 | const notPresentPlayer = await Player.findOne({ where: payload }); 18 | t.falsy(notPresentPlayer); 19 | 20 | const { result, statusCode } = await server.inject({ url, method, payload }); 21 | t.is(statusCode, STATUS_OK); 22 | t.truthy(result.id); 23 | t.is(result.name, payload.name); 24 | }); 25 | 26 | test('not found /notamodel {name: "Chard"}', async (t) => { 27 | const { server } = t.context; 28 | const url = '/notamodel'; 29 | const method = 'POST'; 30 | const payload = { name: 'Chard' }; 31 | 32 | const { statusCode } = await server.inject({ url, method, payload }); 33 | t.is(statusCode, STATUS_NOT_FOUND); 34 | }); 35 | 36 | 37 | test('no payload /player/1', async (t) => { 38 | const { server } = t.context; 39 | const url = '/player'; 40 | const method = 'POST'; 41 | 42 | const { statusCode } = await server.inject({ url, method }); 43 | t.is(statusCode, STATUS_BAD_REQUEST); 44 | }); 45 | -------------------------------------------------------------------------------- /src/crud-update.integration.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'sinon-bluebird'; 3 | import setup from '../test/integration-setup.js'; 4 | 5 | const STATUS_OK = 200; 6 | const STATUS_NOT_FOUND = 404; 7 | const STATUS_BAD_REQUEST = 400; 8 | 9 | setup(test); 10 | 11 | test('where /player/1 {name: "Chard"}', async (t) => { 12 | const { server, instances } = t.context; 13 | const { player1 } = instances; 14 | const url = `/player/${player1.id}`; 15 | const method = 'PUT'; 16 | const payload = { name: 'Chard' }; 17 | 18 | const { result, statusCode } = await server.inject({ url, method, payload }); 19 | t.is(statusCode, STATUS_OK); 20 | t.is(result.id, player1.id); 21 | t.is(result.name, payload.name); 22 | }); 23 | 24 | test('not found /player/10 {name: "Chard"}', async (t) => { 25 | const { server } = t.context; 26 | // this doesn't exist in our fixtures 27 | const url = '/player/10'; 28 | const method = 'PUT'; 29 | const payload = { name: 'Chard' }; 30 | 31 | const { statusCode } = await server.inject({ url, method, payload }); 32 | t.is(statusCode, STATUS_NOT_FOUND); 33 | }); 34 | 35 | 36 | test('no payload /player/1', async (t) => { 37 | const { server, instances } = t.context; 38 | const { player1 } = instances; 39 | const url = `/player/${player1.id}`; 40 | const method = 'PUT'; 41 | 42 | const { statusCode } = await server.inject({ url, method }); 43 | t.is(statusCode, STATUS_BAD_REQUEST); 44 | }); 45 | 46 | test('not found /notamodel {name: "Chard"}', async (t) => { 47 | const { server } = t.context; 48 | const url = '/notamodel'; 49 | const method = 'PUT'; 50 | const payload = { name: 'Chard' }; 51 | 52 | const { statusCode } = await server.inject({ url, method, payload }); 53 | t.is(statusCode, STATUS_NOT_FOUND); 54 | }); 55 | -------------------------------------------------------------------------------- /src/crud-where.integration.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'sinon-bluebird'; 3 | import setup from '../test/integration-setup.js'; 4 | 5 | const STATUS_OK = 200; 6 | const STATUS_NOT_FOUND = 404; 7 | 8 | setup(test); 9 | 10 | test('single result /team?name=Baseball', async (t) => { 11 | const { server, instances } = t.context; 12 | const { team1 } = instances; 13 | const path = `/team?name=${team1.name}`; 14 | 15 | const { result, statusCode } = await server.inject(path); 16 | t.is(statusCode, STATUS_OK); 17 | t.is(result.id, team1.id); 18 | t.is(result.name, team1.name); 19 | }); 20 | 21 | test('no results /team?name=Baseball&id=2', async (t) => { 22 | const { server, instances } = t.context; 23 | const { team1 } = instances; 24 | // this doesn't exist in our fixtures 25 | const path = `/team?name=${team1.name}&id=2`; 26 | 27 | const { statusCode } = await server.inject(path); 28 | t.is(statusCode, STATUS_NOT_FOUND); 29 | }); 30 | 31 | test('single result from list query /teams?name=Baseball', async (t) => { 32 | const { server, instances } = t.context; 33 | const { team1 } = instances; 34 | const path = `/team?name=${team1.name}`; 35 | 36 | const { result, statusCode } = await server.inject(path); 37 | t.is(statusCode, STATUS_OK); 38 | t.is(result.id, team1.id); 39 | t.is(result.name, team1.name); 40 | }); 41 | 42 | test('multiple results from list query /players?teamId=1', async (t) => { 43 | const { server, instances } = t.context; 44 | const { team1, player1, player2 } = instances; 45 | const path = `/players?teamId=${team1.id}`; 46 | 47 | const { result, statusCode } = await server.inject(path); 48 | t.is(statusCode, STATUS_OK); 49 | const playerIds = result.map(({ id }) => id); 50 | t.truthy(playerIds.includes(player1.id)); 51 | t.truthy(playerIds.includes(player2.id)); 52 | }); 53 | 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-sequelize-crud", 3 | "version": "2.9.3", 4 | "description": "Hapi plugin that automatically generates RESTful API for CRUD", 5 | "main": "build/index.js", 6 | "config": { 7 | "ghooks": { 8 | "pre-commit": "npm run lint && npm run build" 9 | } 10 | }, 11 | "scripts": { 12 | "lint": "eslint src", 13 | "test": "SCRIPTY_SILENT=true scripty", 14 | "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov", 15 | "tdd": "ava --watch", 16 | "build": "SCRIPTY_SILENT=true scripty", 17 | "watch": "SCRIPTY_SILENT=true scripty" 18 | }, 19 | "repository": { 20 | "git": "https://github.com/mdibaiee/hapi-sequelize-crud" 21 | }, 22 | "files": [ 23 | "build" 24 | ], 25 | "author": "Mahdi Dibaiee (http://dibaiee.ir/)", 26 | "license": "MIT", 27 | "devDependencies": { 28 | "ava": "^0.16.0", 29 | "babel-cli": "^6.16.0", 30 | "babel-plugin-add-module-exports": "^0.2.1", 31 | "babel-plugin-closure-elimination": "^1.0.6", 32 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 33 | "babel-plugin-transform-es2015-modules-commonjs": "^6.16.0", 34 | "babel-preset-stage-1": "^6.16.0", 35 | "babel-register": "^6.16.3", 36 | "bluebird": "^3.4.6", 37 | "codecov": "^1.0.1", 38 | "eslint": "^3.8.1", 39 | "eslint-config-pichak": "^1.1.2", 40 | "eslint-plugin-ava": "^3.1.1", 41 | "ghooks": "^1.3.2", 42 | "hapi": "^15.2.0", 43 | "hapi-sequelize": "^3.0.4", 44 | "nyc": "^8.3.2", 45 | "portfinder": "^1.0.9", 46 | "scripty": "^1.6.0", 47 | "sequelize": "^3.24.6", 48 | "sinon": "^1.17.6", 49 | "sinon-bluebird": "^3.1.0", 50 | "sqlite3": "^3.1.7", 51 | "tap-xunit": "^1.4.0" 52 | }, 53 | "dependencies": { 54 | "boom": "^4.2.0", 55 | "joi": "^9.2.0", 56 | "lodash": "^4.16.4" 57 | }, 58 | "optionalDependencies": { 59 | "babel-polyfill": "^6.13.0" 60 | }, 61 | "nyc": { 62 | "cache": true 63 | }, 64 | "ava": { 65 | "source": [ 66 | "src/**/*.js", 67 | "!build/**/*" 68 | ], 69 | "files": [ 70 | "**/*.test.js", 71 | "!build/**/*" 72 | ], 73 | "require": [ 74 | "babel-register" 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/error.js: -------------------------------------------------------------------------------- 1 | import Boom from 'boom'; 2 | 3 | export default (target, key, descriptor) => { 4 | const fn = descriptor.value; 5 | 6 | descriptor.value = async (request, reply) => { 7 | try { 8 | await fn(request, reply); 9 | } catch (e) { 10 | if (e.original) { 11 | const { code, detail, hint } = e.original; 12 | let error; 13 | 14 | // pg error codes https://www.postgresql.org/docs/9.5/static/errcodes-appendix.html 15 | if (code && (code.startsWith('22') || code.startsWith('23'))) { 16 | error = Boom.wrap(e, 406); 17 | } else if (code && (code.startsWith('42'))) { 18 | error = Boom.wrap(e, 422); 19 | // TODO: we could get better at parse postgres error codes 20 | } else { 21 | // use a 502 error code since the issue is upstream with postgres, not 22 | // this server 23 | error = Boom.wrap(e, 502); 24 | } 25 | 26 | // detail tends to be more specific information. So, if we have it, use. 27 | if (detail) { 28 | error.message += `: ${detail}`; 29 | error.reformat(); 30 | } 31 | 32 | // hint might provide useful information about how to fix the problem 33 | if (hint) { 34 | error.message += ` Hint: ${hint}`; 35 | error.reformat(); 36 | } 37 | 38 | reply(error); 39 | } else if (!e.isBoom) { 40 | const { message } = e; 41 | let err; 42 | 43 | if (e.name === 'SequelizeValidationError') 44 | err = Boom.badData(message); 45 | else if (e.name === 'SequelizeConnectionTimedOutError') 46 | err = Boom.gatewayTimeout(message); 47 | else if (e.name === 'SequelizeHostNotReachableError') 48 | err = Boom.serverUnavailable(message); 49 | else if (e.name === 'SequelizeUniqueConstraintError') 50 | err = Boom.conflict(message); 51 | else if (e.name === 'SequelizeForeignKeyConstraintError') 52 | err = Boom.expectationFailed(message); 53 | else if (e.name === 'SequelizeExclusionConstraintError') 54 | err = Boom.expectationFailed(message); 55 | else if (e.name === 'SequelizeConnectionError') 56 | err = Boom.badGateway(message); 57 | else err = Boom.badImplementation(message); 58 | 59 | reply(err); 60 | } else { 61 | reply(e); 62 | } 63 | } 64 | }; 65 | 66 | return descriptor; 67 | }; 68 | -------------------------------------------------------------------------------- /test/integration-setup.js: -------------------------------------------------------------------------------- 1 | import hapi from 'hapi'; 2 | import Sequelize from 'sequelize'; 3 | import portfinder from 'portfinder'; 4 | import path from 'path'; 5 | import Promise from 'bluebird'; 6 | 7 | const getPort = Promise.promisify(portfinder.getPort); 8 | const modelsPath = path.join(__dirname, 'fixtures', 'models'); 9 | const modelsGlob = path.join(modelsPath, '**', '*.js'); 10 | const dbName = 'db'; 11 | 12 | // these are what's in the fixtures dir 13 | const modelNames = [ 14 | { Singluar: 'City', singular: 'city', Plural: 'Cities', plural: 'cities' }, 15 | { Singluar: 'Team', singular: 'team', Plural: 'Teams', plural: 'teams' }, 16 | { Singluar: 'Player', singular: 'player', Plural: 'Players', plural: 'players' }, 17 | ]; 18 | 19 | 20 | export default (test) => { 21 | test.beforeEach('get an open port', async (t) => { 22 | t.context.port = await getPort(); 23 | }); 24 | 25 | test.beforeEach('setup server', async (t) => { 26 | const sequelize = t.context.sequelize = new Sequelize({ 27 | dialect: 'sqlite', 28 | logging: false, 29 | }); 30 | 31 | const server = t.context.server = new hapi.Server(); 32 | server.connection({ 33 | host: '0.0.0.0', 34 | port: t.context.port, 35 | }); 36 | 37 | await server.register({ 38 | register: require('hapi-sequelize'), 39 | options: { 40 | name: dbName, 41 | models: [modelsGlob], 42 | sequelize, 43 | sync: true, 44 | forceSync: true, 45 | }, 46 | }); 47 | 48 | await server.register({ 49 | register: require('../src/index.js'), 50 | options: { 51 | name: dbName, 52 | }, 53 | }, 54 | ); 55 | }); 56 | 57 | test.beforeEach('create data', async (t) => { 58 | const { Player, Team, City } = t.context.sequelize.models; 59 | const city1 = await City.create({ name: 'Healdsburg' }); 60 | const team1 = await Team.create({ name: 'Baseballs', cityId: city1.id }); 61 | const team2 = await Team.create({ name: 'Footballs', cityId: city1.id }); 62 | const player1 = await Player.create({ 63 | name: 'Cat', teamId: team1.id, active: true, 64 | }); 65 | const player2 = await Player.create({ name: 'Pinot', teamId: team1.id }); 66 | const player3 = await Player.create({ name: 'Syrah', teamId: team2.id }); 67 | t.context.instances = { city1, team1, team2, player1, player2, player3 }; 68 | }); 69 | 70 | // kill the server so that we can exit and don't leak memory 71 | test.afterEach('stop the server', (t) => t.context.server.stop()); 72 | 73 | return { modelNames }; 74 | }; 75 | -------------------------------------------------------------------------------- /src/utils.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { parseLimitAndOffset, parseOrder, parseWhere } from './utils.js'; 3 | 4 | test.beforeEach((t) => { 5 | const models = t.context.models = { User: {} }; 6 | t.context.request = { query: {}, models }; 7 | }); 8 | 9 | test('parseLimitAndOffset is a function', (t) => { 10 | t.is(typeof parseLimitAndOffset, 'function'); 11 | }); 12 | 13 | test('parseLimitAndOffset returns limit and offset', (t) => { 14 | const { request } = t.context; 15 | request.query.limit = 1; 16 | request.query.offset = 2; 17 | request.query.thing = 'hi'; 18 | 19 | t.is( 20 | parseLimitAndOffset(request).limit 21 | , request.query.limit 22 | ); 23 | 24 | t.is( 25 | parseLimitAndOffset(request).offset 26 | , request.query.offset 27 | ); 28 | }); 29 | 30 | test('parseLimitAndOffset returns limit and offset as numbers', (t) => { 31 | const { request } = t.context; 32 | const limit = 1; 33 | const offset = 2; 34 | request.query.limit = `${limit}`; 35 | request.query.offset = `${offset}`; 36 | request.query.thing = 'hi'; 37 | 38 | t.is( 39 | parseLimitAndOffset(request).limit 40 | , limit 41 | ); 42 | 43 | t.is( 44 | parseLimitAndOffset(request).offset 45 | , offset 46 | ); 47 | }); 48 | 49 | test('parseOrder is a function', (t) => { 50 | t.is(typeof parseOrder, 'function'); 51 | }); 52 | 53 | test('parseOrder returns order when a string', (t) => { 54 | const { request } = t.context; 55 | const order = 'thing'; 56 | request.query.order = order; 57 | request.query.thing = 'hi'; 58 | 59 | t.deepEqual( 60 | parseOrder(request) 61 | , [[order]] 62 | ); 63 | }); 64 | 65 | test('parseOrder returns order when json', (t) => { 66 | const { request,models } = t.context; 67 | request.query.order = [JSON.stringify({ model: 'User' }), 'DESC']; 68 | request.query.thing = 'hi'; 69 | 70 | t.deepEqual( 71 | parseOrder(request) 72 | , [{ model: models.User }, 'DESC'] 73 | ); 74 | }); 75 | 76 | test('parseOrder returns null when not defined', (t) => { 77 | const { request } = t.context; 78 | request.query.thing = 'hi'; 79 | 80 | t.is( 81 | parseOrder(request) 82 | , null 83 | ); 84 | }); 85 | 86 | 87 | test('parseWhere is a function', (t) => { 88 | t.is(typeof parseWhere, 'function'); 89 | }); 90 | 91 | test('parseWhere returns the non-sequelize keys', (t) => { 92 | const { request } = t.context; 93 | request.query.order = 'thing'; 94 | request.query.include = 'User'; 95 | request.query.limit = 2; 96 | request.query.thing = 'hi'; 97 | 98 | t.deepEqual( 99 | parseWhere(request) 100 | , { thing: 'hi' } 101 | ); 102 | }); 103 | 104 | test('parseWhere returns json converted keys', (t) => { 105 | const { request } = t.context; 106 | request.query.order = 'hi'; 107 | request.query.thing = '{"id": {"$in": [2, 3]}}'; 108 | 109 | t.deepEqual( 110 | parseWhere(request) 111 | , { thing: { id: { $in: [2, 3] } } } 112 | ); 113 | }); 114 | -------------------------------------------------------------------------------- /src/crud-include.integration.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'sinon-bluebird'; 3 | import setup from '../test/integration-setup.js'; 4 | 5 | const STATUS_OK = 200; 6 | 7 | setup(test); 8 | 9 | test('belongsTo /team?include=city', async (t) => { 10 | const { server, instances } = t.context; 11 | const { team1, city1 } = instances; 12 | const path = `/team/${team1.id}?include=city`; 13 | 14 | const { result, statusCode } = await server.inject(path); 15 | t.is(statusCode, STATUS_OK); 16 | t.is(result.id, team1.id); 17 | t.is(result.City.id, city1.id); 18 | }); 19 | 20 | test('belongsTo /team?include=cities', async (t) => { 21 | const { server, instances } = t.context; 22 | const { team1, city1 } = instances; 23 | const path = `/team/${team1.id}?include=cities`; 24 | 25 | const { result, statusCode } = await server.inject(path); 26 | t.is(statusCode, STATUS_OK); 27 | t.is(result.id, team1.id); 28 | t.is(result.City.id, city1.id); 29 | }); 30 | 31 | test('hasMany /team?include=player', async (t) => { 32 | const { server, instances } = t.context; 33 | const { team1, player1, player2 } = instances; 34 | const path = `/team/${team1.id}?include=player`; 35 | 36 | const { result, statusCode } = await server.inject(path); 37 | t.is(statusCode, STATUS_OK); 38 | t.is(result.id, team1.id); 39 | 40 | const playerIds = result.Players.map(({ id }) => id); 41 | t.truthy(playerIds.includes(player1.id)); 42 | t.truthy(playerIds.includes(player2.id)); 43 | }); 44 | 45 | test('hasMany /team?include=players', async (t) => { 46 | const { server, instances } = t.context; 47 | const { team1, player1, player2 } = instances; 48 | const path = `/team/${team1.id}?include=players`; 49 | 50 | const { result, statusCode } = await server.inject(path); 51 | t.is(statusCode, STATUS_OK); 52 | t.is(result.id, team1.id); 53 | 54 | const playerIds = result.Players.map(({ id }) => id); 55 | t.truthy(playerIds.includes(player1.id)); 56 | t.truthy(playerIds.includes(player2.id)); 57 | }); 58 | 59 | test('multiple includes /team?include=players&include=city', async (t) => { 60 | const { server, instances } = t.context; 61 | const { team1, player1, player2, city1 } = instances; 62 | const path = `/team/${team1.id}?include=players&include=city`; 63 | 64 | const { result, statusCode } = await server.inject(path); 65 | t.is(statusCode, STATUS_OK); 66 | t.is(result.id, team1.id); 67 | 68 | const playerIds = result.Players.map(({ id }) => id); 69 | t.truthy(playerIds.includes(player1.id)); 70 | t.truthy(playerIds.includes(player2.id)); 71 | t.is(result.City.id, city1.id); 72 | }); 73 | 74 | test('multiple includes /team?include[]=players&include[]=city', async (t) => { 75 | const { server, instances } = t.context; 76 | const { team1, player1, player2, city1 } = instances; 77 | const path = `/team/${team1.id}?include[]=players&include[]=city`; 78 | 79 | const { result, statusCode } = await server.inject(path); 80 | t.is(statusCode, STATUS_OK); 81 | t.is(result.id, team1.id); 82 | 83 | const playerIds = result.Players.map(({ id }) => id); 84 | t.truthy(playerIds.includes(player1.id)); 85 | t.truthy(playerIds.includes(player2.id)); 86 | t.is(result.City.id, city1.id); 87 | }); 88 | -------------------------------------------------------------------------------- /src/crud-list-limit-and-offset.integration.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'sinon-bluebird'; 3 | import setup from '../test/integration-setup.js'; 4 | 5 | const STATUS_OK = 200; 6 | const STATUS_NOT_FOUND = 404; 7 | 8 | setup(test); 9 | 10 | test('/players?limit=2', async (t) => { 11 | const { server } = t.context; 12 | const limit = 2; 13 | const url = `/players?limit=${limit}`; 14 | const method = 'GET'; 15 | 16 | const { result, statusCode } = await server.inject({ url, method }); 17 | t.is(statusCode, STATUS_OK); 18 | t.is(result.length, limit); 19 | }); 20 | 21 | test('/players?limit=2&offset=1', async (t) => { 22 | const { server } = t.context; 23 | const limit = 2; 24 | const url = `/players?limit=${limit}&offset=1`; 25 | const method = 'GET'; 26 | 27 | const { result, statusCode } = await server.inject({ url, method }); 28 | t.is(statusCode, STATUS_OK); 29 | t.is(result.length, limit); 30 | }); 31 | 32 | test('/players?limit=2&offset=2', async (t) => { 33 | const { server } = t.context; 34 | const limit = 2; 35 | const url = `/players?limit=${limit}&offset=2`; 36 | const method = 'GET'; 37 | 38 | const { result, statusCode } = await server.inject({ url, method }); 39 | t.is(statusCode, STATUS_OK); 40 | t.is(result.length, 1, 'with only 3 players, only get 1 back with an offset of 2'); 41 | }); 42 | 43 | test('/players?limit=2&offset=20', async (t) => { 44 | const { server } = t.context; 45 | const limit = 2; 46 | const url = `/players?limit=${limit}&offset=20`; 47 | const method = 'GET'; 48 | 49 | const { statusCode } = await server.inject({ url, method }); 50 | t.is(statusCode, STATUS_NOT_FOUND, 'with a offset/limit greater than the data, returns a 404'); 51 | }); 52 | 53 | test('scope /players/returnsAll?limit=2', async (t) => { 54 | const { server } = t.context; 55 | const limit = 2; 56 | const url = `/players/returnsAll?limit=${limit}`; 57 | const method = 'GET'; 58 | 59 | const { result, statusCode } = await server.inject({ url, method }); 60 | t.is(statusCode, STATUS_OK); 61 | t.is(result.length, limit); 62 | }); 63 | 64 | test('scope /players/returnsAll?limit=2&offset=1', async (t) => { 65 | const { server } = t.context; 66 | const limit = 2; 67 | const url = `/players/returnsAll?limit=${limit}&offset=1`; 68 | const method = 'GET'; 69 | 70 | const { result, statusCode } = await server.inject({ url, method }); 71 | t.is(statusCode, STATUS_OK); 72 | t.is(result.length, limit); 73 | }); 74 | 75 | test('scope /players/returnsAll?limit=2&offset=2', async (t) => { 76 | const { server } = t.context; 77 | const limit = 2; 78 | const url = `/players/returnsAll?limit=${limit}&offset=2`; 79 | const method = 'GET'; 80 | 81 | const { result, statusCode } = await server.inject({ url, method }); 82 | t.is(statusCode, STATUS_OK); 83 | t.is(result.length, 1, 'with only 3 players, only get 1 back with an offset of 2'); 84 | }); 85 | 86 | test('scope /players/returnsAll?limit=2&offset=20', async (t) => { 87 | const { server } = t.context; 88 | const limit = 2; 89 | const url = `/players/returnsAll?limit=${limit}&offset=20`; 90 | const method = 'GET'; 91 | 92 | const { statusCode } = await server.inject({ url, method }); 93 | t.is(statusCode, STATUS_NOT_FOUND, 'with a offset/limit greater than the data, returns a 404'); 94 | }); 95 | -------------------------------------------------------------------------------- /src/associations/one-to-one.js: -------------------------------------------------------------------------------- 1 | import error from '../error'; 2 | import { parseInclude, parseWhere, getMethod } from '../utils'; 3 | 4 | let prefix; 5 | let defaultConfig; 6 | 7 | export default (server, a, b, names, options) => { 8 | prefix = options.prefix; 9 | defaultConfig = options.defaultConfig; 10 | 11 | get(server, a, b, names); 12 | create(server, a, b, names); 13 | destroy(server, a, b, names); 14 | update(server, a, b, names); 15 | }; 16 | 17 | export const get = (server, a, b, names) => { 18 | server.route({ 19 | method: 'GET', 20 | path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}`, 21 | 22 | @error 23 | async handler(request, reply) { 24 | const include = parseInclude(request); 25 | const where = parseWhere(request); 26 | 27 | const base = await a.findOne({ 28 | where: { 29 | [a.primaryKeyField]: request.params.aid, 30 | }, 31 | }); 32 | const method = getMethod(base, names.b, false); 33 | 34 | const list = await method({ where, include, limit: 1 }); 35 | 36 | if (Array.isArray(list)) { 37 | reply(list[0]); 38 | } else { 39 | reply(list); 40 | } 41 | }, 42 | 43 | config: defaultConfig, 44 | }); 45 | }; 46 | 47 | export const create = (server, a, b, names) => { 48 | server.route({ 49 | method: 'POST', 50 | path: `${prefix}/${names.a.singular}/{id}/${names.b.singular}`, 51 | 52 | @error 53 | async handler(request, reply) { 54 | const base = await a.findOne({ 55 | where: { 56 | [a.primaryKeyField]: request.params.id, 57 | }, 58 | }); 59 | 60 | const method = getMethod(base, names.b, false, 'create'); 61 | const instance = await method(request.payload); 62 | 63 | reply(instance); 64 | }, 65 | 66 | config: defaultConfig, 67 | }); 68 | }; 69 | 70 | export const destroy = (server, a, b, names) => { 71 | server.route({ 72 | method: 'DELETE', 73 | path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}/{bid}`, 74 | 75 | @error 76 | async handler(request, reply) { 77 | const include = parseInclude(request); 78 | const where = parseWhere(request); 79 | 80 | const base = await a.findOne({ 81 | where: { 82 | [a.primaryKeyField]: request.params.aid, 83 | }, 84 | }); 85 | 86 | where[b.primaryKeyField] = request.params.bid; 87 | 88 | const method = getMethod(base, names.b, false, 'get'); 89 | const instance = await method({ where, include }); 90 | await instance.destroy(); 91 | 92 | reply(instance); 93 | }, 94 | 95 | config: defaultConfig, 96 | }); 97 | }; 98 | 99 | export const update = (server, a, b, names) => { 100 | server.route({ 101 | method: 'PUT', 102 | path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}/{bid}`, 103 | 104 | @error 105 | async handler(request, reply) { 106 | const include = parseInclude(request); 107 | const where = parseWhere(request); 108 | 109 | const base = await a.findOne({ 110 | where: { 111 | id: request.params.aid, 112 | }, 113 | }); 114 | 115 | where[b.primaryKeyField] = request.params.bid; 116 | 117 | const method = getMethod(base, names.b, false); 118 | 119 | const instance = await method({ where, include }); 120 | await instance.update(request.payload); 121 | 122 | reply(instance); 123 | }, 124 | 125 | config: defaultConfig, 126 | }); 127 | }; 128 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { omit, identity, toNumber, isString, isUndefined } from 'lodash'; 2 | import { notImplemented } from 'boom'; 3 | 4 | const sequelizeKeys = ['include', 'order', 'limit', 'offset']; 5 | 6 | const getModels = (request) => { 7 | const noGetDb = typeof request.getDb !== 'function'; 8 | const noRequestModels = !request.models; 9 | if (noGetDb && noRequestModels) { 10 | return notImplemented('`request.getDb` or `request.models` are not defined.' 11 | + 'Be sure to load hapi-sequelize before hapi-sequelize-crud.'); 12 | } 13 | 14 | const { models } = noGetDb ? request : request.getDb(); 15 | 16 | return models; 17 | }; 18 | 19 | export const parseInclude = request => { 20 | const include = Array.isArray(request.query.include) 21 | ? request.query.include 22 | : [request.query.include] 23 | ; 24 | 25 | const models = getModels(request); 26 | if (models.isBoom) return models; 27 | 28 | return include.map(a => { 29 | const singluarOrPluralMatch = Object.keys(models).find((modelName) => { 30 | const { _singular, _plural } = models[modelName]; 31 | return _singular === a || _plural === a; 32 | }); 33 | 34 | if (singluarOrPluralMatch) return models[singluarOrPluralMatch]; 35 | 36 | if (typeof a === 'string') return models[a]; 37 | 38 | if (a && typeof a.model === 'string' && a.model.length) { 39 | a.model = models[a.model]; 40 | } 41 | 42 | return a; 43 | }).filter(identity); 44 | }; 45 | 46 | export const parseWhere = request => { 47 | const where = omit(request.query, sequelizeKeys); 48 | 49 | for (const key of Object.keys(where)) { 50 | try { 51 | where[key] = JSON.parse(where[key]); 52 | } catch (e) { 53 | // 54 | } 55 | } 56 | 57 | return where; 58 | }; 59 | 60 | export const parseLimitAndOffset = (request) => { 61 | const { limit, offset } = request.query; 62 | const out = {}; 63 | if (!isUndefined(limit)) { 64 | out.limit = toNumber(limit); 65 | } 66 | if (!isUndefined(offset)) { 67 | out.offset = toNumber(offset); 68 | } 69 | return out; 70 | }; 71 | 72 | const parseOrderArray = (order, models) => { 73 | return order.map((requestColumn) => { 74 | if (Array.isArray(requestColumn)) { 75 | return parseOrderArray(requestColumn, models); 76 | } 77 | 78 | let column; 79 | try { 80 | column = JSON.parse(requestColumn); 81 | } catch (e) { 82 | column = requestColumn; 83 | } 84 | 85 | if (column.model) column.model = models[column.model]; 86 | 87 | return column; 88 | }); 89 | }; 90 | 91 | export const parseOrder = (request) => { 92 | const { order } = request.query; 93 | 94 | if (!order) return null; 95 | 96 | const models = getModels(request); 97 | if (models.isBoom) return models; 98 | 99 | // transform to an array so sequelize will escape the input for us and 100 | // maintain security. See http://docs.sequelizejs.com/en/latest/docs/querying/#ordering 101 | const requestOrderColumns = isString(order) ? [order.split(' ')] : order; 102 | 103 | const parsedOrder = parseOrderArray(requestOrderColumns, models); 104 | 105 | return parsedOrder; 106 | }; 107 | 108 | export const getMethod = (model, association, plural = true, method = 'get') => { 109 | const a = plural ? association.original.plural : association.original.singular; 110 | const b = plural ? association.original.singular : association.original.plural; // alternative 111 | const fn = model[`${method}${a}`] || model[`${method}${b}`]; 112 | if (fn) return fn.bind(model); 113 | 114 | return false; 115 | }; 116 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | if (!global._babelPolyfill) { 2 | require('babel-polyfill'); 3 | } 4 | 5 | import crud, { associations } from './crud'; 6 | import url from 'url'; 7 | import qs from 'qs'; 8 | 9 | const register = (server, options = {}, next) => { 10 | options.prefix = options.prefix || '/'; 11 | options.name = options.name || 'db'; 12 | 13 | const db = server.plugins['hapi-sequelize'][options.name]; 14 | const models = db.sequelize.models; 15 | 16 | const onRequest = function (request, reply) { 17 | const uri = request.raw.req.url; 18 | const parsed = url.parse(uri, false); 19 | parsed.query = qs.parse(parsed.query); 20 | request.setUrl(parsed); 21 | 22 | return reply.continue(); 23 | }; 24 | 25 | server.ext({ 26 | type: 'onRequest', 27 | method: onRequest, 28 | }); 29 | 30 | for (const modelName of Object.keys(models)) { 31 | const model = models[modelName]; 32 | const { plural, singular } = model.options.name; 33 | model._plural = plural.toLowerCase(); 34 | model._singular = singular.toLowerCase(); 35 | model._Plural = plural; 36 | model._Singular = singular; 37 | 38 | // Join tables 39 | if (model.options.name.singular !== model.name) continue; 40 | 41 | for (const key of Object.keys(model.associations)) { 42 | const association = model.associations[key]; 43 | const { source, target } = association; 44 | 45 | const sourceName = source.options.name; 46 | 47 | const names = (rev) => { 48 | const arr = [{ 49 | plural: sourceName.plural.toLowerCase(), 50 | singular: sourceName.singular.toLowerCase(), 51 | original: sourceName, 52 | }, { 53 | plural: association.options.name.plural.toLowerCase(), 54 | singular: association.options.name.singular.toLowerCase(), 55 | original: association.options.name, 56 | }]; 57 | 58 | return rev ? { b: arr[0], a: arr[1] } : { a: arr[0], b: arr[1] }; 59 | }; 60 | 61 | const targetAssociations = target.associations[sourceName.plural] 62 | || target.associations[sourceName.singular]; 63 | const sourceType = association.associationType, 64 | targetType = (targetAssociations || {}).associationType; 65 | 66 | try { 67 | if (sourceType === 'BelongsTo' && (targetType === 'BelongsTo' || !targetType)) { 68 | associations.oneToOne(server, source, target, names(), options); 69 | associations.oneToOne(server, target, source, names(1), options); 70 | } 71 | 72 | if (sourceType === 'BelongsTo' && targetType === 'HasMany') { 73 | associations.oneToOne(server, source, target, names(), options); 74 | associations.oneToOne(server, target, source, names(1), options); 75 | associations.oneToMany(server, target, source, names(1), options); 76 | } 77 | 78 | if (sourceType === 'BelongsToMany' && targetType === 'BelongsToMany') { 79 | associations.oneToOne(server, source, target, names(), options); 80 | associations.oneToOne(server, target, source, names(1), options); 81 | 82 | associations.oneToMany(server, source, target, names(), options); 83 | associations.oneToMany(server, target, source, names(1), options); 84 | } 85 | 86 | associations.associate(server, source, target, names(), options); 87 | associations.associate(server, target, source, names(1), options); 88 | } catch (e) { 89 | // There might be conflicts in case of models associated with themselves and some other 90 | // rare cases. 91 | } 92 | } 93 | } 94 | 95 | // build the methods for each model now that we've defined all the 96 | // associations 97 | Object.keys(models).filter((modelName) => { 98 | const model = models[modelName]; 99 | return model.options.name.singular === model.name; 100 | }).forEach((modelName) => { 101 | const model = models[modelName]; 102 | crud(server, model, options); 103 | }); 104 | 105 | 106 | next(); 107 | }; 108 | 109 | register.attributes = { 110 | pkg: require('../package.json'), 111 | }; 112 | 113 | export { register }; 114 | -------------------------------------------------------------------------------- /src/get-config-for-method.js: -------------------------------------------------------------------------------- 1 | import { set, get } from 'lodash'; 2 | import joi from 'joi'; 3 | 4 | // if the custom validation is a joi object we need to concat 5 | // else, assume it's an plain object and we can just add it in with .keys 6 | const concatToJoiObject = (joi, candidate) => { 7 | if (!candidate) return joi; 8 | else if (candidate.isJoi) return joi.concat(candidate); 9 | else return joi.keys(candidate); 10 | }; 11 | 12 | 13 | export const sequelizeOperators = { 14 | $and: joi.any(), 15 | $or: joi.any(), 16 | $gt: joi.any(), 17 | $gte: joi.any(), 18 | $lt: joi.any(), 19 | $lte: joi.any(), 20 | $ne: joi.any(), 21 | $eq: joi.any(), 22 | $not: joi.any(), 23 | $between: joi.any(), 24 | $notBetween: joi.any(), 25 | $in: joi.any(), 26 | $notIn: joi.any(), 27 | $like: joi.any(), 28 | $notLike: joi.any(), 29 | $iLike: joi.any(), 30 | $notILike: joi.any(), 31 | $overlap: joi.any(), 32 | $contains: joi.any(), 33 | $contained: joi.any(), 34 | $any: joi.any(), 35 | $col: joi.any(), 36 | }; 37 | 38 | export const whereMethods = [ 39 | 'list', 40 | 'get', 41 | 'scope', 42 | 'destroy', 43 | 'destoryScope', 44 | 'destroyAll', 45 | ]; 46 | 47 | export const includeMethods = [ 48 | 'list', 49 | 'get', 50 | 'scope', 51 | 'destoryScope', 52 | ]; 53 | 54 | export const payloadMethods = [ 55 | 'create', 56 | 'update', 57 | ]; 58 | 59 | export const scopeParamsMethods = [ 60 | 'destroyScope', 61 | 'scope', 62 | ]; 63 | 64 | export const idParamsMethods = [ 65 | 'get', 66 | 'update', 67 | ]; 68 | 69 | export const restrictMethods = [ 70 | 'list', 71 | 'scope', 72 | ]; 73 | 74 | export default ({ 75 | method, attributeValidation, associationValidation, scopes = [], config = {}, 76 | }) => { 77 | const hasWhere = whereMethods.includes(method); 78 | const hasInclude = includeMethods.includes(method); 79 | const hasPayload = payloadMethods.includes(method); 80 | const hasScopeParams = scopeParamsMethods.includes(method); 81 | const hasIdParams = idParamsMethods.includes(method); 82 | const hasRestrictMethods = restrictMethods.includes(method); 83 | // clone the config so we don't modify it on multiple passes. 84 | let methodConfig = { ...config, validate: { ...config.validate } }; 85 | 86 | if (hasWhere) { 87 | const query = concatToJoiObject(joi.object() 88 | .keys({ 89 | ...attributeValidation, 90 | ...sequelizeOperators, 91 | }), 92 | get(methodConfig, 'validate.query') 93 | ); 94 | 95 | methodConfig = set(methodConfig, 'validate.query', query); 96 | } 97 | 98 | if (hasInclude) { 99 | const query = concatToJoiObject(joi.object() 100 | .keys({ 101 | ...associationValidation, 102 | }), 103 | get(methodConfig, 'validate.query') 104 | ); 105 | 106 | methodConfig = set(methodConfig, 'validate.query', query); 107 | } 108 | 109 | if (hasPayload) { 110 | const payload = concatToJoiObject(joi.object() 111 | .keys({ 112 | ...attributeValidation, 113 | }), 114 | get(methodConfig, 'validate.payload') 115 | ); 116 | 117 | methodConfig = set(methodConfig, 'validate.payload', payload); 118 | } 119 | 120 | if (hasScopeParams) { 121 | const params = concatToJoiObject(joi.object() 122 | .keys({ 123 | scope: joi.string().valid(...scopes), 124 | }), 125 | get(methodConfig, 'validate.params') 126 | ); 127 | 128 | methodConfig = set(methodConfig, 'validate.params', params); 129 | } 130 | 131 | if (hasIdParams) { 132 | const params = concatToJoiObject(joi.object() 133 | .keys({ 134 | id: joi.any(), 135 | }), 136 | get(methodConfig, 'validate.params') 137 | ); 138 | 139 | methodConfig = set(methodConfig, 'validate.params', params); 140 | } 141 | 142 | if (hasRestrictMethods) { 143 | const query = concatToJoiObject(joi.object() 144 | .keys({ 145 | limit: joi.number().min(0).integer(), 146 | offset: joi.number().min(0).integer(), 147 | order: [joi.array(), joi.string()], 148 | }), 149 | get(methodConfig, 'validate.query') 150 | ); 151 | 152 | methodConfig = set(methodConfig, 'validate.query', query); 153 | } 154 | 155 | return methodConfig; 156 | }; 157 | -------------------------------------------------------------------------------- /src/crud-list-order.integration.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'sinon-bluebird'; 3 | import setup from '../test/integration-setup.js'; 4 | 5 | const STATUS_OK = 200; 6 | const STATUS_BAD_QUERY = 502; 7 | 8 | setup(test); 9 | 10 | test('/players?order=name', async (t) => { 11 | const { server, instances } = t.context; 12 | const { player1, player2, player3 } = instances; 13 | const url = '/players?order=name'; 14 | const method = 'GET'; 15 | 16 | const { result, statusCode } = await server.inject({ url, method }); 17 | t.is(statusCode, STATUS_OK); 18 | // this is the order we'd expect the names to be in 19 | t.is(result[0].name, player1.name); 20 | t.is(result[1].name, player2.name); 21 | t.is(result[2].name, player3.name); 22 | }); 23 | 24 | test('/players?order=name%20ASC', async (t) => { 25 | const { server, instances } = t.context; 26 | const { player1, player2, player3 } = instances; 27 | const url = '/players?order=name%20ASC'; 28 | const method = 'GET'; 29 | 30 | const { result, statusCode } = await server.inject({ url, method }); 31 | t.is(statusCode, STATUS_OK); 32 | // this is the order we'd expect the names to be in 33 | t.is(result[0].name, player1.name); 34 | t.is(result[1].name, player2.name); 35 | t.is(result[2].name, player3.name); 36 | }); 37 | 38 | test('/players?order=name%20DESC', async (t) => { 39 | const { server, instances } = t.context; 40 | const { player1, player2, player3 } = instances; 41 | const url = '/players?order=name%20DESC'; 42 | const method = 'GET'; 43 | 44 | const { result, statusCode } = await server.inject({ url, method }); 45 | t.is(statusCode, STATUS_OK); 46 | // this is the order we'd expect the names to be in 47 | t.is(result[0].name, player3.name); 48 | t.is(result[1].name, player2.name); 49 | t.is(result[2].name, player1.name); 50 | }); 51 | 52 | test('/players?order[]=name', async (t) => { 53 | const { server, instances } = t.context; 54 | const { player1, player2, player3 } = instances; 55 | const url = '/players?order[]=name'; 56 | const method = 'GET'; 57 | 58 | const { result, statusCode } = await server.inject({ url, method }); 59 | t.is(statusCode, STATUS_OK); 60 | // this is the order we'd expect the names to be in 61 | t.is(result[0].name, player1.name); 62 | t.is(result[1].name, player2.name); 63 | t.is(result[2].name, player3.name); 64 | }); 65 | 66 | test('/players?order[0]=name&order[0]=DESC', async (t) => { 67 | const { server, instances } = t.context; 68 | const { player1, player2, player3 } = instances; 69 | const url = '/players?order[0]=name&order[0]=DESC'; 70 | const method = 'GET'; 71 | 72 | const { result, statusCode } = await server.inject({ url, method }); 73 | t.is(statusCode, STATUS_OK); 74 | // this is the order we'd expect the names to be in 75 | t.is(result[0].name, player3.name); 76 | t.is(result[1].name, player2.name); 77 | t.is(result[2].name, player1.name); 78 | }); 79 | 80 | // multiple sorts 81 | test('/players?order[0]=active&order[0]=DESC&order[1]=name&order[1]=DESC', async (t) => { 82 | const { server, instances } = t.context; 83 | const { player1, player2, player3 } = instances; 84 | const url = '/players?order[0]=name&order[0]=DESC&order[1]=teamId&order[1]=DESC'; 85 | const method = 'GET'; 86 | 87 | const { result, statusCode } = await server.inject({ url, method }); 88 | t.is(statusCode, STATUS_OK); 89 | // this is the order we'd expect the names to be in 90 | t.is(result[0].name, player3.name); 91 | t.is(result[1].name, player2.name); 92 | t.is(result[2].name, player1.name); 93 | }); 94 | 95 | // this will fail b/c sequelize doesn't correctly do the join when you pass 96 | // an order. There are many issues for this: 97 | // eslint-disable-next-line 98 | // https://github.com/sequelize/sequelize/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20order%20join%20 99 | // 100 | // https://github.com/sequelize/sequelize/issues/5353 is a good example 101 | // if this test passes, that's great! Just remove the workaround note in the 102 | // docs 103 | // eslint-disable-next-line 104 | test.failing('sequelize bug /players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC', async (t) => { 105 | const { server, instances } = t.context; 106 | const { player1, player2, player3 } = instances; 107 | const url = '/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC'; 108 | const method = 'GET'; 109 | 110 | const { result, statusCode } = await server.inject({ url, method }); 111 | t.is(statusCode, STATUS_OK); 112 | // this is the order we'd expect the names to be in 113 | t.is(result[0].name, player3.name); 114 | t.is(result[1].name, player1.name); 115 | t.is(result[2].name, player2.name); 116 | }); 117 | 118 | // b/c the above fails, this is a work-around 119 | test('/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC&include=team', async (t) => { 120 | const { server, instances } = t.context; 121 | const { player1, player2, player3 } = instances; 122 | const url = '/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC&include=team'; 123 | const method = 'GET'; 124 | 125 | const { result, statusCode } = await server.inject({ url, method }); 126 | t.is(statusCode, STATUS_OK); 127 | // this is the order we'd expect the names to be in 128 | t.is(result[0].name, player3.name); 129 | t.is(result[1].name, player1.name); 130 | t.is(result[2].name, player2.name); 131 | }); 132 | 133 | test('invalid column /players?order[0]=invalid', async (t) => { 134 | const { server } = t.context; 135 | const url = '/players?order[]=invalid'; 136 | const method = 'GET'; 137 | 138 | const { statusCode, result } = await server.inject({ url, method }); 139 | t.is(statusCode, STATUS_BAD_QUERY); 140 | t.truthy(result.message.includes('invalid')); 141 | }); 142 | -------------------------------------------------------------------------------- /src/crud.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { list } from './crud.js'; 3 | import { stub } from 'sinon'; 4 | import uniqueId from 'lodash/uniqueId.js'; 5 | import 'sinon-bluebird'; 6 | 7 | const METHODS = { 8 | GET: 'GET', 9 | }; 10 | 11 | test.beforeEach('setup server', (t) => { 12 | t.context.server = { 13 | route: stub(), 14 | }; 15 | }); 16 | 17 | const makeModel = () => { 18 | const id = uniqueId(); 19 | return { 20 | findAll: stub(), 21 | _plural: 'models', 22 | _singular: 'model', 23 | toJSON: () => ({ id }), 24 | id, 25 | }; 26 | }; 27 | 28 | test.beforeEach('setup model', (t) => { 29 | t.context.model = makeModel(); 30 | }); 31 | 32 | test.beforeEach('setup models', (t) => { 33 | t.context.models = [t.context.model, makeModel()]; 34 | }); 35 | 36 | test.beforeEach('setup request stub', (t) => { 37 | t.context.request = { 38 | query: {}, 39 | payload: {}, 40 | models: t.context.models, 41 | }; 42 | }); 43 | 44 | test.beforeEach('setup reply stub', (t) => { 45 | t.context.reply = stub(); 46 | }); 47 | 48 | test('crud#list without prefix', (t) => { 49 | const { server, model } = t.context; 50 | 51 | list({ server, model }); 52 | const { path } = server.route.args[0][0]; 53 | 54 | t.falsy( 55 | path.includes('undefined'), 56 | 'correctly sets the path without a prefix defined', 57 | ); 58 | 59 | t.is( 60 | path, 61 | `/${model._plural}`, 62 | 'the path sets to the plural model' 63 | ); 64 | }); 65 | 66 | test('crud#list with prefix', (t) => { 67 | const { server, model } = t.context; 68 | const prefix = '/v1'; 69 | 70 | list({ server, model, prefix }); 71 | const { path } = server.route.args[0][0]; 72 | 73 | t.is( 74 | path, 75 | `${prefix}/${model._plural}`, 76 | 'the path sets to the plural model with the prefix' 77 | ); 78 | }); 79 | 80 | test('crud#list method', (t) => { 81 | const { server, model } = t.context; 82 | 83 | list({ server, model }); 84 | const { method } = server.route.args[0][0]; 85 | 86 | t.is( 87 | method, 88 | METHODS.GET, 89 | `sets the method to ${METHODS.GET}` 90 | ); 91 | }); 92 | 93 | test('crud#list config', (t) => { 94 | const { server, model } = t.context; 95 | const userConfig = {}; 96 | 97 | list({ server, model, config: userConfig }); 98 | const { config } = server.route.args[0][0]; 99 | 100 | t.is( 101 | config, 102 | userConfig, 103 | 'sets the user config' 104 | ); 105 | }); 106 | 107 | test('crud#list handler', async (t) => { 108 | const { server, model, request, reply, models } = t.context; 109 | 110 | list({ server, model }); 111 | const { handler } = server.route.args[0][0]; 112 | model.findAll.resolves(models); 113 | 114 | try { 115 | await handler(request, reply); 116 | } catch (e) { 117 | t.ifError(e, 'does not error while handling'); 118 | } finally { 119 | t.pass('does not error while handling'); 120 | } 121 | 122 | t.truthy( 123 | reply.calledOnce 124 | , 'calls reply only once' 125 | ); 126 | 127 | const response = reply.args[0][0]; 128 | 129 | t.falsy(response instanceof Error, response); 130 | 131 | t.deepEqual( 132 | response, 133 | models.map(({ id }) => ({ id })), 134 | 'responds with the list of models' 135 | ); 136 | }); 137 | 138 | test('crud#list handler if parseInclude errors', async (t) => { 139 | const { server, model, request, reply } = t.context; 140 | // we _want_ the error 141 | delete request.models; 142 | 143 | list({ server, model }); 144 | const { handler } = server.route.args[0][0]; 145 | 146 | await handler(request, reply); 147 | 148 | t.truthy( 149 | reply.calledOnce 150 | , 'calls reply only once' 151 | ); 152 | 153 | const response = reply.args[0][0]; 154 | 155 | t.truthy( 156 | response.isBoom, 157 | 'responds with a Boom error' 158 | ); 159 | }); 160 | 161 | test('crud#list handler with limit', async (t) => { 162 | const { server, model, request, reply, models } = t.context; 163 | const { findAll } = model; 164 | 165 | // set the limit 166 | request.query.limit = 1; 167 | 168 | list({ server, model }); 169 | const { handler } = server.route.args[0][0]; 170 | model.findAll.resolves(models); 171 | 172 | try { 173 | await handler(request, reply); 174 | } catch (e) { 175 | t.ifError(e, 'does not error while handling'); 176 | } finally { 177 | t.pass('does not error while handling'); 178 | } 179 | 180 | t.truthy( 181 | reply.calledOnce 182 | , 'calls reply only once' 183 | ); 184 | 185 | const response = reply.args[0][0]; 186 | const findAllArgs = findAll.args[0][0]; 187 | 188 | t.falsy(response instanceof Error, response); 189 | 190 | t.is( 191 | findAllArgs.limit, 192 | request.query.limit, 193 | 'queries with the limit' 194 | ); 195 | }); 196 | 197 | test('crud#list handler with order', async (t) => { 198 | const { server, model, request, reply, models } = t.context; 199 | const { findAll } = model; 200 | 201 | // set the limit 202 | request.query.order = 'key'; 203 | 204 | list({ server, model }); 205 | const { handler } = server.route.args[0][0]; 206 | model.findAll.resolves(models); 207 | 208 | try { 209 | await handler(request, reply); 210 | } catch (e) { 211 | t.ifError(e, 'does not error while handling'); 212 | } finally { 213 | t.pass('does not error while handling'); 214 | } 215 | 216 | t.truthy( 217 | reply.calledOnce 218 | , 'calls reply only once' 219 | ); 220 | 221 | const response = reply.args[0][0]; 222 | const findAllArgs = findAll.args[0][0]; 223 | 224 | t.falsy(response instanceof Error, response); 225 | 226 | t.deepEqual( 227 | findAllArgs.order, 228 | [[request.query.order]], 229 | 'queries with the order as an array b/c that\'s what sequelize wants' 230 | ); 231 | }); 232 | -------------------------------------------------------------------------------- /src/crud-destroy.integration.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'sinon-bluebird'; 3 | import setup from '../test/integration-setup.js'; 4 | 5 | const STATUS_OK = 200; 6 | const STATUS_NOT_FOUND = 404; 7 | const STATUS_BAD_REQUEST = 400; 8 | 9 | setup(test); 10 | 11 | test('destroy where /player?name=Baseball', async (t) => { 12 | const { server, instances, sequelize: { models: { Player } } } = t.context; 13 | const { player1, player2 } = instances; 14 | const url = `/player?name=${player1.name}`; 15 | const method = 'DELETE'; 16 | 17 | const presentPlayer = await Player.findById(player1.id); 18 | t.truthy(presentPlayer); 19 | 20 | const { result, statusCode } = await server.inject({ url, method }); 21 | t.is(statusCode, STATUS_OK); 22 | t.is(result.id, player1.id); 23 | 24 | const deletedPlayer = await Player.findById(player1.id); 25 | t.falsy(deletedPlayer); 26 | const stillTherePlayer = await Player.findById(player2.id); 27 | t.truthy(stillTherePlayer); 28 | }); 29 | 30 | test('destroyAll where /players?name=Baseball', async (t) => { 31 | const { server, instances, sequelize: { models: { Player } } } = t.context; 32 | const { player1, player2 } = instances; 33 | const url = `/players?name=${player1.name}`; 34 | const method = 'DELETE'; 35 | 36 | const presentPlayer = await Player.findById(player1.id); 37 | t.truthy(presentPlayer); 38 | 39 | const { result, statusCode } = await server.inject({ url, method }); 40 | t.is(statusCode, STATUS_OK); 41 | t.is(result.id, player1.id); 42 | 43 | const deletedPlayer = await Player.findById(player1.id); 44 | t.falsy(deletedPlayer); 45 | const stillTherePlayer = await Player.findById(player2.id); 46 | t.truthy(stillTherePlayer); 47 | }); 48 | 49 | test('destroyAll /players', async (t) => { 50 | const { server, instances, sequelize: { models: { Player } } } = t.context; 51 | const { player1, player2 } = instances; 52 | const url = '/players'; 53 | const method = 'DELETE'; 54 | 55 | const presentPlayers = await Player.findAll(); 56 | const playerIds = presentPlayers.map(({ id }) => id); 57 | t.truthy(playerIds.includes(player1.id)); 58 | t.truthy(playerIds.includes(player2.id)); 59 | 60 | const { result, statusCode } = await server.inject({ url, method }); 61 | t.is(statusCode, STATUS_OK); 62 | const resultPlayerIds = result.map(({ id }) => id); 63 | t.truthy(resultPlayerIds.includes(player1.id)); 64 | t.truthy(resultPlayerIds.includes(player2.id)); 65 | 66 | const deletedPlayers = await Player.findAll(); 67 | t.is(deletedPlayers.length, 0); 68 | }); 69 | 70 | test('destroy not found /player/10', async (t) => { 71 | const { server, instances, sequelize: { models: { Player } } } = t.context; 72 | const { player1, player2 } = instances; 73 | // this doesn't exist in our fixtures 74 | const url = '/player/10'; 75 | const method = 'DELETE'; 76 | 77 | const presentPlayers = await Player.findAll(); 78 | const playerIds = presentPlayers.map(({ id }) => id); 79 | t.truthy(playerIds.includes(player1.id)); 80 | t.truthy(playerIds.includes(player2.id)); 81 | 82 | const { statusCode } = await server.inject({ url, method }); 83 | t.is(statusCode, STATUS_NOT_FOUND); 84 | 85 | const nonDeletedPlayers = await Player.findAll(); 86 | t.is(nonDeletedPlayers.length, presentPlayers.length); 87 | }); 88 | 89 | test('destroyAll not found /players?name=no', async (t) => { 90 | const { server, instances, sequelize: { models: { Player } } } = t.context; 91 | const { player1, player2 } = instances; 92 | // this doesn't exist in our fixtures 93 | const url = '/players?name=no'; 94 | const method = 'DELETE'; 95 | 96 | const presentPlayers = await Player.findAll(); 97 | const playerIds = presentPlayers.map(({ id }) => id); 98 | t.truthy(playerIds.includes(player1.id)); 99 | t.truthy(playerIds.includes(player2.id)); 100 | 101 | const { statusCode } = await server.inject({ url, method }); 102 | t.is(statusCode, STATUS_NOT_FOUND); 103 | 104 | const nonDeletedPlayers = await Player.findAll(); 105 | t.is(nonDeletedPlayers.length, presentPlayers.length); 106 | }); 107 | 108 | test('not found /notamodel', async (t) => { 109 | const { server } = t.context; 110 | const url = '/notamodel'; 111 | const method = 'DELETE'; 112 | 113 | const { statusCode } = await server.inject({ url, method }); 114 | t.is(statusCode, STATUS_NOT_FOUND); 115 | }); 116 | 117 | test('destroyScope /players/returnsOne', async (t) => { 118 | const { server, instances, sequelize: { models: { Player } } } = t.context; 119 | const { player1, player2 } = instances; 120 | // this doesn't exist in our fixtures 121 | const url = '/players/returnsOne'; 122 | const method = 'DELETE'; 123 | 124 | const presentPlayers = await Player.findAll(); 125 | const playerIds = presentPlayers.map(({ id }) => id); 126 | t.truthy(playerIds.includes(player1.id)); 127 | t.truthy(playerIds.includes(player2.id)); 128 | 129 | const { result, statusCode } = await server.inject({ url, method }); 130 | t.is(statusCode, STATUS_OK); 131 | t.is(result.id, player1.id); 132 | 133 | const nonDeletedPlayers = await Player.findAll(); 134 | t.is(nonDeletedPlayers.length, presentPlayers.length - 1); 135 | }); 136 | 137 | test('destroyScope /players/returnsNone', async (t) => { 138 | const { server, instances, sequelize: { models: { Player } } } = t.context; 139 | const { player1, player2 } = instances; 140 | // this doesn't exist in our fixtures 141 | const url = '/players/returnsNone'; 142 | const method = 'DELETE'; 143 | 144 | const presentPlayers = await Player.findAll(); 145 | const playerIds = presentPlayers.map(({ id }) => id); 146 | t.truthy(playerIds.includes(player1.id)); 147 | t.truthy(playerIds.includes(player2.id)); 148 | 149 | const { statusCode } = await server.inject({ url, method }); 150 | t.is(statusCode, STATUS_NOT_FOUND); 151 | 152 | const nonDeletedPlayers = await Player.findAll(); 153 | const nonDeletedPlayerIds = nonDeletedPlayers.map(({ id }) => id); 154 | t.truthy(nonDeletedPlayerIds.includes(player1.id)); 155 | t.truthy(nonDeletedPlayerIds.includes(player2.id)); 156 | }); 157 | 158 | test('destroyScope invalid scope /players/invalid', async (t) => { 159 | const { server, instances, sequelize: { models: { Player } } } = t.context; 160 | const { player1, player2 } = instances; 161 | // this doesn't exist in our fixtures 162 | const url = '/players/invalid'; 163 | const method = 'DELETE'; 164 | 165 | const presentPlayers = await Player.findAll(); 166 | const playerIds = presentPlayers.map(({ id }) => id); 167 | t.truthy(playerIds.includes(player1.id)); 168 | t.truthy(playerIds.includes(player2.id)); 169 | 170 | const { statusCode } = await server.inject({ url, method }); 171 | t.is(statusCode, STATUS_BAD_REQUEST); 172 | 173 | const nonDeletedPlayers = await Player.findAll(); 174 | const nonDeletedPlayerIds = nonDeletedPlayers.map(({ id }) => id); 175 | t.truthy(nonDeletedPlayerIds.includes(player1.id)); 176 | t.truthy(nonDeletedPlayerIds.includes(player2.id)); 177 | }); 178 | -------------------------------------------------------------------------------- /src/associations/one-to-many.js: -------------------------------------------------------------------------------- 1 | import joi from 'joi'; 2 | import error from '../error'; 3 | import _ from 'lodash'; 4 | import { parseInclude, parseWhere, getMethod } from '../utils'; 5 | 6 | let prefix; 7 | let defaultConfig; 8 | 9 | export default (server, a, b, names, options) => { 10 | prefix = options.prefix; 11 | defaultConfig = options.defaultConfig; 12 | 13 | get(server, a, b, names); 14 | list(server, a, b, names); 15 | scope(server, a, b, names); 16 | scopeScope(server, a, b, names); 17 | destroy(server, a, b, names); 18 | destroyScope(server, a, b, names); 19 | update(server, a, b, names); 20 | }; 21 | 22 | export const get = (server, a, b, names) => { 23 | server.route({ 24 | method: 'GET', 25 | path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}/{bid}`, 26 | 27 | @error 28 | async handler(request, reply) { 29 | const include = parseInclude(request); 30 | 31 | const base = await a.findOne({ 32 | where: { 33 | [a.primaryKeyField]: request.params.aid, 34 | }, 35 | }); 36 | 37 | const method = getMethod(base, names.b); 38 | 39 | const list = await method({ where: { 40 | [b.primaryKeyField]: request.params.bid, 41 | }, include }); 42 | 43 | if (Array.isArray(list)) { 44 | reply(list[0]); 45 | } else { 46 | reply(list); 47 | } 48 | }, 49 | 50 | config: defaultConfig, 51 | }); 52 | }; 53 | 54 | export const list = (server, a, b, names) => { 55 | server.route({ 56 | method: 'GET', 57 | path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}`, 58 | 59 | @error 60 | async handler(request, reply) { 61 | const include = parseInclude(request); 62 | const where = parseWhere(request); 63 | 64 | const base = await a.findOne({ 65 | where: { 66 | [a.primaryKeyField]: request.params.aid, 67 | }, 68 | }); 69 | 70 | const method = getMethod(base, names.b); 71 | const list = await method({ where, include }); 72 | 73 | reply(list); 74 | }, 75 | 76 | config: defaultConfig, 77 | }); 78 | }; 79 | 80 | export const scope = (server, a, b, names) => { 81 | const scopes = Object.keys(b.options.scopes); 82 | 83 | server.route({ 84 | method: 'GET', 85 | path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}/{scope}`, 86 | 87 | @error 88 | async handler(request, reply) { 89 | const include = parseInclude(request); 90 | const where = parseWhere(request); 91 | 92 | const base = await a.findOne({ 93 | where: { 94 | [a.primaryKeyField]: request.params.aid, 95 | }, 96 | }); 97 | 98 | const method = getMethod(base, names.b); 99 | const list = await method({ 100 | scope: request.params.scope, 101 | where, 102 | include, 103 | }); 104 | 105 | reply(list); 106 | }, 107 | 108 | config: _.defaultsDeep({ 109 | validate: { 110 | params: joi.object().keys({ 111 | scope: joi.string().valid(...scopes), 112 | aid: joi.number().integer().required(), 113 | }), 114 | }, 115 | }, defaultConfig), 116 | }); 117 | }; 118 | 119 | export const scopeScope = (server, a, b, names) => { 120 | const scopes = { 121 | a: Object.keys(a.options.scopes), 122 | b: Object.keys(b.options.scopes), 123 | }; 124 | 125 | server.route({ 126 | method: 'GET', 127 | path: `${prefix}/${names.a.plural}/{scopea}/${names.b.plural}/{scopeb}`, 128 | 129 | @error 130 | async handler(request, reply) { 131 | const include = parseInclude(request); 132 | const where = parseWhere(request); 133 | 134 | const list = await b.scope(request.params.scopeb).findAll({ 135 | where, 136 | include: include.concat({ 137 | model: a.scope(request.params.scopea), 138 | }), 139 | }); 140 | 141 | reply(list); 142 | }, 143 | 144 | config: _.defaultsDeep({ 145 | validate: { 146 | params: joi.object().keys({ 147 | scopea: joi.string().valid(...scopes.a), 148 | scopeb: joi.string().valid(...scopes.b), 149 | }), 150 | }, 151 | }, defaultConfig), 152 | }); 153 | }; 154 | 155 | export const destroy = (server, a, b, names) => { 156 | server.route({ 157 | method: 'DELETE', 158 | path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}`, 159 | 160 | @error 161 | async handler(request, reply) { 162 | const include = parseInclude(request); 163 | const where = parseWhere(request); 164 | 165 | const base = await a.findOne({ 166 | where: { 167 | [a.primaryKeyField]: request.params.aid, 168 | }, 169 | }); 170 | 171 | const method = getMethod(base, names.b, true, 'get'); 172 | const list = await method({ where, include }); 173 | await Promise.all(list.map(item => 174 | item.destroy() 175 | )); 176 | 177 | reply(list); 178 | }, 179 | }); 180 | }; 181 | 182 | export const destroyScope = (server, a, b, names) => { 183 | const scopes = Object.keys(b.options.scopes); 184 | 185 | server.route({ 186 | method: 'DELETE', 187 | path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}/{scope}`, 188 | 189 | @error 190 | async handler(request, reply) { 191 | const include = parseInclude(request); 192 | const where = parseWhere(request); 193 | 194 | const base = await a.findOne({ 195 | where: { 196 | [a.primarykeyField]: request.params.aid, 197 | }, 198 | }); 199 | 200 | const method = getMethod(base, names.b, true, 'get'); 201 | 202 | const list = await method({ 203 | scope: request.params.scope, 204 | where, 205 | include, 206 | }); 207 | 208 | await Promise.all(list.map(instance => instance.destroy())); 209 | 210 | reply(list); 211 | }, 212 | 213 | config: _.defaultsDeep({ 214 | validate: { 215 | params: joi.object().keys({ 216 | scope: joi.string().valid(...scopes), 217 | aid: joi.number().integer().required(), 218 | }), 219 | }, 220 | }, defaultConfig), 221 | }); 222 | }; 223 | 224 | export const update = (server, a, b, names) => { 225 | server.route({ 226 | method: 'PUT', 227 | path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}`, 228 | 229 | @error 230 | async handler(request, reply) { 231 | const include = parseInclude(request); 232 | const where = parseWhere(request); 233 | 234 | const base = await a.findOne({ 235 | where: { 236 | [a.primaryKeyField]: request.params.aid, 237 | }, 238 | }); 239 | 240 | const method = getMethod(base, names.b); 241 | const list = await method({ where, include }); 242 | 243 | await Promise.all(list.map(instance => instance.update(request.payload))); 244 | 245 | reply(list); 246 | }, 247 | 248 | config: defaultConfig, 249 | }); 250 | }; 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | hapi-sequelize-crud [![CircleCI](https://circleci.com/gh/mdibaiee/hapi-sequelize-crud.svg?style=svg)](https://circleci.com/gh/mdibaiee/hapi-sequelize-crud) 2 | =================== 3 | 4 | Automatically generate a RESTful API for your models and associations 5 | 6 | This plugin depends on [`hapi-sequelize`](https://github.com/danecando/hapi-sequelize). 7 | 8 | ``` 9 | npm install -S hapi-sequelize-crud 10 | ``` 11 | 12 | ## Configure 13 | 14 | Please note that you should register `hapi-sequelize-crud` after defining your 15 | associations. 16 | 17 | ```javascript 18 | // First, register hapi-sequelize 19 | await register({ 20 | register: require('hapi-sequelize'), 21 | options: { ... } 22 | }); 23 | 24 | // Then, define your associations 25 | let db = server.plugins['hapi-sequelize'].db; 26 | let models = db.sequelize.models; 27 | associations(models); // pretend this function defines our associations 28 | 29 | // Now, register hapi-sequelize-crud 30 | await register({ 31 | register: require('hapi-sequelize-crud'), 32 | options: { 33 | prefix: '/v1', 34 | name: 'db', // the same name you used for configuring `hapi-sequelize` (options.name) 35 | defaultConfig: { ... }, // passed as `config` to all routes created 36 | 37 | // You can specify which models must have routes defined for using the 38 | // `models` property. If you omit this property, all models will have 39 | // models defined for them. e.g. 40 | models: ['cat', 'dog'] // only the cat and dog models will have routes created 41 | 42 | // or 43 | models: [ 44 | // possible methods: list, get, scope, create, destroy, destroyAll, destroyScope, update 45 | // the cat model only has get and list methods enabled 46 | {model: 'cat', methods: ['get', 'list']}, 47 | // the dog model has all methods enabled 48 | {model: 'dog'}, 49 | // the cow model also has all methods enabled 50 | 'cow', 51 | // the bat model as a custom config for the list method, but uses the default config for create. 52 | // `config` if provided, overrides the default config 53 | {model: 'bat', methods: ['list'], config: { ... }}, 54 | {model: 'bat', methods: ['create']} 55 | {model: 'fly', config: { 56 | // interact with the request before hapi-sequelize-crud does 57 | , ext: { 58 | onPreHandler: (request, reply) => { 59 | if (request.auth.hasAccessToFly) reply.continue() 60 | else reply(Boom.unauthorized()) 61 | } 62 | } 63 | // change the response data 64 | response: { 65 | schema: {id: joi.string()}, 66 | modify: true 67 | } 68 | }} 69 | ] 70 | } 71 | }); 72 | ``` 73 | 74 | ### Methods 75 | * **list**: get all rows in a table 76 | * **get**: get a single row 77 | * **scope**: reference a [sequelize scope](http://docs.sequelizejs.com/en/latest/api/model/#scopeoptions-model) 78 | * **create**: create a new row 79 | * **destroy**: delete a row 80 | * **destroyAll**: delete all models in the table 81 | * **destroyScope**: use a [sequelize scope](http://docs.sequelizejs.com/en/latest/api/model/#scopeoptions-model) to find rows, then delete them 82 | * **update**: update a row 83 | 84 | ## `where` queries 85 | It's easy to restrict your requests using Sequelize's `where` query option. Just pass a query parameter. 86 | 87 | ```js 88 | // returns only teams that have a `city` property of "windsor" 89 | // GET /team?city=windsor 90 | 91 | // results in the Sequelize query: 92 | Team.findOne({ where: { city: 'windsor' }}) 93 | ``` 94 | 95 | You can also do more complex queries by setting the value of a key to JSON. 96 | 97 | ```js 98 | // returns only teams that have a `address.city` property of "windsor" 99 | // GET /team?city={"address": "windsor"} 100 | // or 101 | // GET /team?city[address]=windsor 102 | 103 | // results in the Sequelize query: 104 | Team.findOne({ where: { address: { city: 'windsor' }}}) 105 | ``` 106 | 107 | ## `include` queries 108 | Getting related models is easy, just use a query parameter `include`. 109 | 110 | ```js 111 | // returns all teams with their related City model 112 | // GET /teams?include=city 113 | 114 | // results in a Sequelize query: 115 | Team.findAll({include: City}) 116 | ``` 117 | 118 | If you want to get multiple related models, just pass multiple `include` parameters. 119 | ```js 120 | // returns all teams with their related City and Uniform models 121 | // GET /teams?include[]=city&include[]=uniform 122 | 123 | // results in a Sequelize query: 124 | Team.findAll({include: [City, Uniform]}) 125 | ``` 126 | 127 | For models that have a many-to-many relationship, you can also pass the plural version of the association. 128 | ```js 129 | // returns all teams with their related City and Uniform models 130 | // GET /teams?include=players 131 | 132 | // results in a Sequelize query: 133 | Team.findAll({include: [Player]}) 134 | ``` 135 | 136 | ## `limit` and `offset` queries 137 | Restricting list (`GET`) and scope queries to a restricted count can be done by passing `limit=` and/or `offset=`. 138 | 139 | ```js 140 | // returns 10 teams starting from the 10th 141 | // GET /teams?limit=10&offset=10 142 | 143 | // results in a Sequelize query: 144 | Team.findAll({limit: 10, offset: 10}) 145 | ``` 146 | 147 | ## `order` queries 148 | You can change the order of the resulting query by passing `order` to the query. 149 | 150 | ```js 151 | // returns the teams ordered by the name column 152 | // GET /teams?order[]=name 153 | 154 | // results in a Sequelize query: 155 | Team.findAll({order: ['name']}) 156 | ``` 157 | 158 | ```js 159 | // returns the teams ordered by the name column, descending 160 | // GET /teams?order[0]=name&order[0]=DESC 161 | // GET /teams?order=name%20DESC 162 | 163 | // results in a Sequelize query: 164 | Team.findAll({order: [['name', 'DESC']]}) 165 | ``` 166 | 167 | ```js 168 | // returns the teams ordered by the name, then the city columns, descending 169 | // GET /teams?order[0]=name&order[1]=city 170 | 171 | // results in a Sequelize query: 172 | Team.findAll({order: [['name'], ['city']]}) 173 | ``` 174 | 175 | You can even order by associated models. Though there is a [sequelize bug](https://github.com/sequelize/sequelize/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20order%20join%20) that might prevent this from working properly. A workaround is to `&include` the model you're ordering by. 176 | ```js 177 | // returns the players ordered by the team name 178 | // GET /players?order[0]={"model": "Team"}&order[0]=name 179 | 180 | // results in a Sequelize query: 181 | Player.findAll({order: [[{model: Team}, 'name']]}) 182 | 183 | // if the above returns a Sequelize error: `No such column Team.name`, 184 | // you can work around this by forcing the join into the query: 185 | // GET /players?order[0]={"model": "Team"}&order[0]=name&include=team 186 | 187 | // results in a Sequelize query: 188 | Player.findAll({order: [[{model: Team}, 'name']], include: [Team]}) 189 | ``` 190 | 191 | 192 | ## Authorization and other hooks 193 | You can use Hapi's [`ext` option](http://hapijs.com/api#route-options) to interact with the request both before and after this module does. This is useful if you want to enforce authorization, or modify the request before or after this module does. Hapi [has a full list of hooks](http://hapijs.com/api#request-lifecycle) you can use. 194 | 195 | ## Modify the response format 196 | By default, `hapi-sequelize-crud` routes will respond with the full model. You can modify this using the built-in [hapi settings](http://hapijs.com/tutorials/validation#output). 197 | 198 | ```js 199 | await register({ 200 | register: require('hapi-sequelize-crud'), 201 | options: { 202 | … 203 | {model: 'fly', config: { 204 | response: { 205 | // setting this schema will restrict the response to only the id 206 | schema: { id: joi.string() }, 207 | // This tells Hapi to restrict the response to the keys specified in `schema` 208 | modify: true 209 | } 210 | }} 211 | } 212 | 213 | }) 214 | ``` 215 | 216 | ## Full list of methods 217 | 218 | Let's say you have a `many-to-many` association like this: 219 | 220 | ```javascript 221 | Team.belongsToMany(Role, { through: 'TeamRoles' }); 222 | Role.belongsToMany(Team, { through: 'TeamRoles' }); 223 | ``` 224 | 225 | You get these: 226 | 227 | ``` 228 | # get an array of records 229 | GET /team/{id}/roles 230 | GET /role/{id}/teams 231 | # might also append `where` query parameters to search for 232 | GET /role/{id}/teams?members=5 233 | GET /role/{id}/teams?city=healdsburg 234 | 235 | # you might also use scopes 236 | GET /teams/{scope}/roles/{scope} 237 | GET /team/{id}/roles/{scope} 238 | GET /roles/{scope}/teams/{scope} 239 | GET /roles/{id}/teams/{scope} 240 | 241 | # get a single record 242 | GET /team/{id}/role/{id} 243 | GET /role/{id}/team/{id} 244 | 245 | # create 246 | POST /team/{id}/role 247 | POST /role/{id}/team 248 | 249 | # update 250 | PUT /team/{id}/role/{id} 251 | PUT /role/{id}/team/{id} 252 | 253 | # delete 254 | DELETE /team/{id}/roles #search and destroy 255 | DELETE /role/{id}/teams?members=5 256 | 257 | DELETE /team/{id}/role/{id} 258 | DELETE /role/{id}/team/{id} 259 | 260 | # include 261 | # include nested associations (you can specify an array if includes) 262 | GET /team/{id}/role/{id}?include=SomeRoleAssociation 263 | 264 | # you also get routes to associate objects with each other 265 | GET /associate/role/{id}/employee/{id} # associates role {id} with employee {id} 266 | 267 | # you can specify a prefix to change the URLs like this: 268 | GET /v1/team/{id}/roles 269 | ``` 270 | -------------------------------------------------------------------------------- /src/crud.js: -------------------------------------------------------------------------------- 1 | import joi from 'joi'; 2 | import path from 'path'; 3 | import error from './error'; 4 | import _ from 'lodash'; 5 | import { parseInclude, parseWhere, parseLimitAndOffset, parseOrder } from './utils'; 6 | import { notFound } from 'boom'; 7 | import * as associations from './associations/index'; 8 | import getConfigForMethod from './get-config-for-method.js'; 9 | 10 | const createAll = ({ 11 | server, 12 | model, 13 | prefix, 14 | config, 15 | attributeValidation, 16 | associationValidation, 17 | scopes, 18 | }) => { 19 | Object.keys(methods).forEach((method) => { 20 | methods[method]({ 21 | server, 22 | model, 23 | prefix, 24 | config: getConfigForMethod({ 25 | method, 26 | attributeValidation, 27 | associationValidation, 28 | config, 29 | scopes, 30 | }), 31 | }); 32 | }); 33 | }; 34 | 35 | export { associations }; 36 | 37 | /* 38 | The `models` option, becomes `permissions`, and can look like: 39 | 40 | ``` 41 | models: ['cat', 'dog'] 42 | ``` 43 | 44 | or 45 | 46 | ``` 47 | models: { 48 | cat: ['list', 'get'] 49 | , dog: true // all 50 | } 51 | ``` 52 | 53 | */ 54 | 55 | export default (server, model, { prefix, defaultConfig: config, models: permissions }) => { 56 | const modelName = model._singular; 57 | const modelAttributes = Object.keys(model.attributes); 58 | const associatedModelNames = Object.keys(model.associations); 59 | const modelAssociations = [ 60 | ...associatedModelNames, 61 | ..._.flatMap(associatedModelNames, (associationName) => { 62 | const { target } = model.associations[associationName]; 63 | const { _singular, _plural, _Singular, _Plural } = target; 64 | return [_singular, _plural, _Singular, _Plural]; 65 | }), 66 | ].filter(Boolean); 67 | 68 | const attributeValidation = modelAttributes.reduce((params, attribute) => { 69 | // TODO: use joi-sequelize 70 | params[attribute] = joi.any(); 71 | return params; 72 | }, {}); 73 | 74 | const validAssociations = modelAssociations.length 75 | ? joi.string().valid(...modelAssociations) 76 | : joi.valid(null); 77 | const associationValidation = { 78 | include: [joi.array().items(validAssociations), validAssociations], 79 | }; 80 | 81 | const scopes = Object.keys(model.options.scopes); 82 | 83 | // if we don't have any permissions set, just create all the methods 84 | if (!permissions) { 85 | createAll({ 86 | server, 87 | model, 88 | prefix, 89 | config, 90 | attributeValidation, 91 | associationValidation, 92 | scopes, 93 | }); 94 | // if permissions are set, but we can't parse them, throw an error 95 | } else if (!Array.isArray(permissions)) { 96 | throw new Error('hapi-sequelize-crud: `models` property must be an array'); 97 | // if permissions are set, but the only thing we've got is a model name, there 98 | // are no permissions to be set, so just create all methods and move on 99 | } else if (permissions.includes(modelName)) { 100 | createAll({ 101 | server, 102 | model, 103 | prefix, 104 | config, 105 | attributeValidation, 106 | associationValidation, 107 | scopes, 108 | }); 109 | // if we've gotten here, we have complex permissions and need to set them 110 | } else { 111 | const permissionOptions = permissions.filter((permission) => { 112 | return permission.model === modelName; 113 | }); 114 | 115 | permissionOptions.forEach((permissionOption) => { 116 | if (_.isPlainObject(permissionOption)) { 117 | const permissionConfig = permissionOption.config || config; 118 | 119 | if (permissionOption.methods) { 120 | permissionOption.methods.forEach((method) => { 121 | methods[method]({ 122 | server, 123 | model, 124 | prefix, 125 | config: getConfigForMethod({ 126 | method, 127 | attributeValidation, 128 | associationValidation, 129 | scopes, 130 | config: permissionConfig, 131 | }), 132 | }); 133 | }); 134 | } else { 135 | createAll({ 136 | server, 137 | model, 138 | prefix, 139 | attributeValidation, 140 | associationValidation, 141 | scopes, 142 | config: permissionConfig, 143 | }); 144 | } 145 | } 146 | }); 147 | } 148 | }; 149 | 150 | export const list = ({ server, model, prefix = '/', config }) => { 151 | server.route({ 152 | method: 'GET', 153 | path: path.join(prefix, model._plural), 154 | 155 | @error 156 | async handler(request, reply) { 157 | const include = parseInclude(request); 158 | const where = parseWhere(request); 159 | const { limit, offset } = parseLimitAndOffset(request); 160 | const order = parseOrder(request); 161 | 162 | if (include instanceof Error) return void reply(include); 163 | 164 | const list = await model.findAll({ 165 | where, include, limit, offset, order, 166 | }); 167 | 168 | if (!list.length) return void reply(notFound('Nothing found.')); 169 | 170 | reply(list.map((item) => item.toJSON())); 171 | }, 172 | 173 | config, 174 | }); 175 | }; 176 | 177 | export const get = ({ server, model, prefix = '/', config }) => { 178 | server.route({ 179 | method: 'GET', 180 | path: path.join(prefix, model._singular, '{id?}'), 181 | 182 | @error 183 | async handler(request, reply) { 184 | const include = parseInclude(request); 185 | const where = parseWhere(request); 186 | const { id } = request.params; 187 | if (id) where[model.primaryKeyField] = id; 188 | 189 | if (include instanceof Error) return void reply(include); 190 | 191 | const instance = await model.findOne({ where, include }); 192 | 193 | if (!instance) return void reply(notFound(`${id} not found.`)); 194 | 195 | reply(instance.toJSON()); 196 | }, 197 | config, 198 | }); 199 | }; 200 | 201 | export const scope = ({ server, model, prefix = '/', config }) => { 202 | server.route({ 203 | method: 'GET', 204 | path: path.join(prefix, model._plural, '{scope}'), 205 | 206 | @error 207 | async handler(request, reply) { 208 | const include = parseInclude(request); 209 | const where = parseWhere(request); 210 | const { limit, offset } = parseLimitAndOffset(request); 211 | const order = parseOrder(request); 212 | 213 | if (include instanceof Error) return void reply(include); 214 | 215 | const list = await model.scope(request.params.scope).findAll({ 216 | include, where, limit, offset, order, 217 | }); 218 | 219 | if (!list.length) return void reply(notFound('Nothing found.')); 220 | 221 | reply(list.map((item) => item.toJSON())); 222 | }, 223 | config, 224 | }); 225 | }; 226 | 227 | export const create = ({ server, model, prefix = '/', config }) => { 228 | server.route({ 229 | method: 'POST', 230 | path: path.join(prefix, model._singular), 231 | 232 | @error 233 | async handler(request, reply) { 234 | const instance = await model.create(request.payload); 235 | 236 | reply(instance.toJSON()); 237 | }, 238 | 239 | config, 240 | }); 241 | }; 242 | 243 | export const destroy = ({ server, model, prefix = '/', config }) => { 244 | server.route({ 245 | method: 'DELETE', 246 | path: path.join(prefix, model._singular, '{id?}'), 247 | 248 | @error 249 | async handler(request, reply) { 250 | const where = parseWhere(request); 251 | const { id } = request.params; 252 | if (id) where[model.primaryKeyField] = id; 253 | 254 | const list = await model.findAll({ where }); 255 | 256 | if (!list.length) { 257 | return void reply(id 258 | ? notFound(`${id} not found.`) 259 | : notFound('Nothing found.') 260 | ); 261 | } 262 | 263 | await Promise.all(list.map(instance => instance.destroy())); 264 | 265 | const listAsJSON = list.map((item) => item.toJSON()); 266 | reply(listAsJSON.length === 1 ? listAsJSON[0] : listAsJSON); 267 | }, 268 | 269 | config, 270 | }); 271 | }; 272 | 273 | export const destroyAll = ({ server, model, prefix = '/', config }) => { 274 | server.route({ 275 | method: 'DELETE', 276 | path: path.join(prefix, model._plural), 277 | 278 | @error 279 | async handler(request, reply) { 280 | const where = parseWhere(request); 281 | const { id } = request.params; 282 | 283 | const list = await model.findAll({ where }); 284 | 285 | if (!list.length) { 286 | return void reply(id 287 | ? notFound(`${id} not found.`) 288 | : notFound('Nothing found.') 289 | ); 290 | } 291 | 292 | await Promise.all(list.map(instance => instance.destroy())); 293 | 294 | const listAsJSON = list.map((item) => item.toJSON()); 295 | reply(listAsJSON.length === 1 ? listAsJSON[0] : listAsJSON); 296 | }, 297 | 298 | config, 299 | }); 300 | }; 301 | 302 | export const destroyScope = ({ server, model, prefix = '/', config }) => { 303 | server.route({ 304 | method: 'DELETE', 305 | path: path.join(prefix, model._plural, '{scope}'), 306 | 307 | @error 308 | async handler(request, reply) { 309 | const include = parseInclude(request); 310 | const where = parseWhere(request); 311 | 312 | if (include instanceof Error) return void reply(include); 313 | 314 | const list = await model.scope(request.params.scope).findAll({ include, where }); 315 | 316 | if (!list.length) return void reply(notFound('Nothing found.')); 317 | 318 | await Promise.all(list.map(instance => instance.destroy())); 319 | 320 | const listAsJSON = list.map((item) => item.toJSON()); 321 | reply(listAsJSON.length === 1 ? listAsJSON[0] : listAsJSON); 322 | }, 323 | config, 324 | }); 325 | }; 326 | 327 | export const update = ({ server, model, prefix = '/', config }) => { 328 | server.route({ 329 | method: 'PUT', 330 | path: path.join(prefix, model._singular, '{id}'), 331 | 332 | @error 333 | async handler(request, reply) { 334 | const { id } = request.params; 335 | const instance = await model.findById(id); 336 | 337 | if (!instance) return void reply(notFound(`${id} not found.`)); 338 | 339 | await instance.update(request.payload); 340 | 341 | reply(instance.toJSON()); 342 | }, 343 | 344 | config, 345 | }); 346 | }; 347 | 348 | const methods = { 349 | list, get, scope, create, destroy, destroyAll, destroyScope, update, 350 | }; 351 | -------------------------------------------------------------------------------- /src/get-config-for-method.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import joi from 'joi'; 3 | import 4 | getConfigForMethod, { 5 | whereMethods, 6 | includeMethods, 7 | payloadMethods, 8 | scopeParamsMethods, 9 | idParamsMethods, 10 | restrictMethods, 11 | sequelizeOperators, 12 | } from './get-config-for-method.js'; 13 | 14 | test.beforeEach((t) => { 15 | t.context.models = ['MyModel']; 16 | 17 | t.context.scopes = ['aScope']; 18 | 19 | t.context.attributeValidation = { 20 | myKey: joi.any(), 21 | }; 22 | 23 | t.context.associationValidation = { 24 | include: joi.array().items(joi.string().valid(t.context.models)), 25 | }; 26 | 27 | t.context.config = { 28 | cors: {}, 29 | }; 30 | }); 31 | 32 | test('validate.query seqeulizeOperators', (t) => { 33 | whereMethods.forEach((method) => { 34 | const configForMethod = getConfigForMethod({ method }); 35 | const { query } = configForMethod.validate; 36 | 37 | t.truthy( 38 | query, 39 | `applies query validation for ${method}` 40 | ); 41 | 42 | Object.keys(sequelizeOperators).forEach((operator) => { 43 | t.ifError( 44 | query.validate({ [operator]: true }).error 45 | , `applies sequelize operator "${operator}" in validate.where for ${method}` 46 | ); 47 | }); 48 | 49 | t.truthy( 50 | query.validate({ notAThing: true }).error 51 | , 'errors on a non-valid key' 52 | ); 53 | }); 54 | }); 55 | 56 | test('validate.query attributeValidation', (t) => { 57 | const { attributeValidation } = t.context; 58 | 59 | whereMethods.forEach((method) => { 60 | const configForMethod = getConfigForMethod({ method, attributeValidation }); 61 | const { query } = configForMethod.validate; 62 | 63 | Object.keys(attributeValidation).forEach((key) => { 64 | t.ifError( 65 | query.validate({ [key]: true }).error 66 | , `applies attributeValidation (${key}) to validate.query` 67 | ); 68 | }); 69 | 70 | t.truthy( 71 | query.validate({ notAThing: true }).error 72 | , 'errors on a non-valid key' 73 | ); 74 | }); 75 | }); 76 | 77 | test('query attributeValidation w/ config as plain object', (t) => { 78 | const { attributeValidation } = t.context; 79 | const config = { 80 | validate: { 81 | query: { 82 | aKey: joi.boolean(), 83 | }, 84 | }, 85 | }; 86 | 87 | whereMethods.forEach((method) => { 88 | const configForMethod = getConfigForMethod({ 89 | method, 90 | attributeValidation, 91 | config, 92 | }); 93 | const { query } = configForMethod.validate; 94 | 95 | const keys = [ 96 | ...Object.keys(attributeValidation), 97 | ...Object.keys(config.validate.query), 98 | ]; 99 | 100 | keys.forEach((key) => { 101 | t.ifError( 102 | query.validate({ [key]: true }).error 103 | , `applies ${key} to validate.query` 104 | ); 105 | }); 106 | 107 | t.truthy( 108 | query.validate({ notAThing: true }).error 109 | , 'errors on a non-valid key' 110 | ); 111 | }); 112 | }); 113 | 114 | test('query attributeValidation w/ config as joi object', (t) => { 115 | const { attributeValidation } = t.context; 116 | const queryKeys = { 117 | aKey: joi.boolean(), 118 | }; 119 | const config = { 120 | validate: { 121 | query: joi.object().keys(queryKeys), 122 | }, 123 | }; 124 | 125 | whereMethods.forEach((method) => { 126 | const configForMethod = getConfigForMethod({ 127 | method, 128 | attributeValidation, 129 | config, 130 | }); 131 | const { query } = configForMethod.validate; 132 | 133 | const keys = [ 134 | ...Object.keys(attributeValidation), 135 | ...Object.keys(queryKeys), 136 | ]; 137 | 138 | keys.forEach((key) => { 139 | t.ifError( 140 | query.validate({ [key]: true }).error 141 | , `applies ${key} to validate.query` 142 | ); 143 | }); 144 | 145 | t.truthy( 146 | query.validate({ notAThing: true }).error 147 | , 'errors on a non-valid key' 148 | ); 149 | }); 150 | }); 151 | 152 | test('validate.query associationValidation', (t) => { 153 | const { attributeValidation, associationValidation, models } = t.context; 154 | 155 | includeMethods.forEach((method) => { 156 | const configForMethod = getConfigForMethod({ 157 | method, 158 | attributeValidation, 159 | associationValidation, 160 | }); 161 | const { query } = configForMethod.validate; 162 | 163 | Object.keys(attributeValidation).forEach((key) => { 164 | t.ifError( 165 | query.validate({ [key]: true }).error 166 | , `applies attributeValidation (${key}) to validate.query when include should be applied` 167 | ); 168 | }); 169 | 170 | Object.keys(associationValidation).forEach((key) => { 171 | t.ifError( 172 | query.validate({ [key]: models }).error 173 | , `applies associationValidation (${key}) to validate.query when include should be applied` 174 | ); 175 | }); 176 | 177 | t.truthy( 178 | query.validate({ notAThing: true }).error 179 | , 'errors on a non-valid key' 180 | ); 181 | }); 182 | }); 183 | 184 | test('query associationValidation w/ config as plain object', (t) => { 185 | const { associationValidation, models } = t.context; 186 | const config = { 187 | validate: { 188 | query: { 189 | aKey: joi.boolean(), 190 | }, 191 | }, 192 | }; 193 | 194 | includeMethods.forEach((method) => { 195 | const configForMethod = getConfigForMethod({ 196 | method, 197 | associationValidation, 198 | config, 199 | }); 200 | const { query } = configForMethod.validate; 201 | 202 | Object.keys(associationValidation).forEach((key) => { 203 | t.ifError( 204 | query.validate({ [key]: models }).error 205 | , `applies ${key} to validate.query` 206 | ); 207 | }); 208 | 209 | Object.keys(config.validate.query).forEach((key) => { 210 | t.ifError( 211 | query.validate({ [key]: true }).error 212 | , `applies ${key} to validate.query` 213 | ); 214 | }); 215 | 216 | t.truthy( 217 | query.validate({ notAThing: true }).error 218 | , 'errors on a non-valid key' 219 | ); 220 | }); 221 | }); 222 | 223 | test('query associationValidation w/ config as joi object', (t) => { 224 | const { associationValidation, models } = t.context; 225 | const queryKeys = { 226 | aKey: joi.boolean(), 227 | }; 228 | const config = { 229 | validate: { 230 | query: joi.object().keys(queryKeys), 231 | }, 232 | }; 233 | 234 | includeMethods.forEach((method) => { 235 | const configForMethod = getConfigForMethod({ 236 | method, 237 | associationValidation, 238 | config, 239 | }); 240 | const { query } = configForMethod.validate; 241 | 242 | Object.keys(associationValidation).forEach((key) => { 243 | t.ifError( 244 | query.validate({ [key]: models }).error 245 | , `applies ${key} to validate.query` 246 | ); 247 | }); 248 | 249 | Object.keys(queryKeys).forEach((key) => { 250 | t.ifError( 251 | query.validate({ [key]: true }).error 252 | , `applies ${key} to validate.query` 253 | ); 254 | }); 255 | 256 | t.truthy( 257 | query.validate({ notAThing: true }).error 258 | , 'errors on a non-valid key' 259 | ); 260 | }); 261 | }); 262 | 263 | test('validate.payload associationValidation', (t) => { 264 | const { attributeValidation } = t.context; 265 | 266 | payloadMethods.forEach((method) => { 267 | const configForMethod = getConfigForMethod({ method, attributeValidation }); 268 | const { payload } = configForMethod.validate; 269 | 270 | Object.keys(attributeValidation).forEach((key) => { 271 | t.ifError( 272 | payload.validate({ [key]: true }).error 273 | , `applies attributeValidation (${key}) to validate.payload` 274 | ); 275 | }); 276 | 277 | t.truthy( 278 | payload.validate({ notAThing: true }).error 279 | , 'errors on a non-valid key' 280 | ); 281 | }); 282 | }); 283 | 284 | test('payload attributeValidation w/ config as plain object', (t) => { 285 | const { attributeValidation } = t.context; 286 | const config = { 287 | validate: { 288 | payload: { 289 | aKey: joi.boolean(), 290 | }, 291 | }, 292 | }; 293 | 294 | payloadMethods.forEach((method) => { 295 | const configForMethod = getConfigForMethod({ 296 | method, 297 | attributeValidation, 298 | config, 299 | }); 300 | const { payload } = configForMethod.validate; 301 | 302 | const keys = [ 303 | ...Object.keys(attributeValidation), 304 | ...Object.keys(config.validate.payload), 305 | ]; 306 | 307 | keys.forEach((key) => { 308 | t.ifError( 309 | payload.validate({ [key]: true }).error 310 | , `applies ${key} to validate.payload` 311 | ); 312 | }); 313 | 314 | t.truthy( 315 | payload.validate({ notAThing: true }).error 316 | , 'errors on a non-valid key' 317 | ); 318 | }); 319 | }); 320 | 321 | test('payload attributeValidation w/ config as joi object', (t) => { 322 | const { attributeValidation } = t.context; 323 | const payloadKeys = { 324 | aKey: joi.boolean(), 325 | }; 326 | const config = { 327 | validate: { 328 | payload: joi.object().keys(payloadKeys), 329 | }, 330 | }; 331 | 332 | payloadMethods.forEach((method) => { 333 | const configForMethod = getConfigForMethod({ 334 | method, 335 | attributeValidation, 336 | config, 337 | }); 338 | const { payload } = configForMethod.validate; 339 | 340 | const keys = [ 341 | ...Object.keys(attributeValidation), 342 | ...Object.keys(payloadKeys), 343 | ]; 344 | 345 | keys.forEach((key) => { 346 | t.ifError( 347 | payload.validate({ [key]: true }).error 348 | , `applies ${key} to validate.payload` 349 | ); 350 | }); 351 | 352 | t.truthy( 353 | payload.validate({ notAThing: true }).error 354 | , 'errors on a non-valid key' 355 | ); 356 | }); 357 | }); 358 | 359 | test('validate.params scopeParamsMethods', (t) => { 360 | const { scopes } = t.context; 361 | 362 | scopeParamsMethods.forEach((method) => { 363 | const configForMethod = getConfigForMethod({ method, scopes }); 364 | const { params } = configForMethod.validate; 365 | 366 | scopes.forEach((key) => { 367 | t.ifError( 368 | params.validate({ scope: key }).error 369 | , `applies "scope: ${key}" to validate.params` 370 | ); 371 | }); 372 | 373 | t.truthy( 374 | params.validate({ scope: 'notAthing' }).error 375 | , 'errors on a non-valid key' 376 | ); 377 | }); 378 | }); 379 | 380 | test('params scopeParamsMethods w/ config as plain object', (t) => { 381 | const { scopes } = t.context; 382 | const config = { 383 | validate: { 384 | params: { 385 | aKey: joi.boolean(), 386 | }, 387 | }, 388 | }; 389 | 390 | scopeParamsMethods.forEach((method) => { 391 | const configForMethod = getConfigForMethod({ 392 | method, 393 | scopes, 394 | config, 395 | }); 396 | const { params } = configForMethod.validate; 397 | 398 | scopes.forEach((key) => { 399 | t.ifError( 400 | params.validate({ scope: key }).error 401 | , `applies "scope: ${key}" to validate.params` 402 | ); 403 | }); 404 | 405 | Object.keys(config.validate.params).forEach((key) => { 406 | t.ifError( 407 | params.validate({ [key]: true }).error 408 | , `applies ${key} to validate.params` 409 | ); 410 | }); 411 | 412 | t.truthy( 413 | params.validate({ notAThing: true }).error 414 | , 'errors on a non-valid key' 415 | ); 416 | }); 417 | }); 418 | 419 | test('params scopeParamsMethods w/ config as joi object', (t) => { 420 | const { scopes } = t.context; 421 | const paramsKeys = { 422 | aKey: joi.boolean(), 423 | }; 424 | const config = { 425 | validate: { 426 | params: joi.object().keys(paramsKeys), 427 | }, 428 | }; 429 | 430 | scopeParamsMethods.forEach((method) => { 431 | const configForMethod = getConfigForMethod({ 432 | method, 433 | scopes, 434 | config, 435 | }); 436 | const { params } = configForMethod.validate; 437 | 438 | scopes.forEach((key) => { 439 | t.ifError( 440 | params.validate({ scope: key }).error 441 | , `applies "scope: ${key}" to validate.params` 442 | ); 443 | }); 444 | 445 | Object.keys(paramsKeys).forEach((key) => { 446 | t.ifError( 447 | params.validate({ [key]: true }).error 448 | , `applies ${key} to validate.params` 449 | ); 450 | }); 451 | 452 | t.truthy( 453 | params.validate({ notAThing: true }).error 454 | , 'errors on a non-valid key' 455 | ); 456 | }); 457 | }); 458 | 459 | 460 | test('validate.payload idParamsMethods', (t) => { 461 | idParamsMethods.forEach((method) => { 462 | const configForMethod = getConfigForMethod({ method }); 463 | const { params } = configForMethod.validate; 464 | 465 | t.ifError( 466 | params.validate({ id: 'aThing' }).error 467 | , 'applies id to validate.params' 468 | ); 469 | }); 470 | }); 471 | 472 | test('validate.query restrictMethods', (t) => { 473 | restrictMethods.forEach((method) => { 474 | const configForMethod = getConfigForMethod({ method }); 475 | const { query } = configForMethod.validate; 476 | const restrictKeys = ['limit', 'offset']; 477 | 478 | restrictKeys.forEach((key) => { 479 | t.ifError( 480 | query.validate({ [key]: 0 }).error 481 | , `applies restriction (${key}) to validate.query` 482 | ); 483 | }); 484 | 485 | t.ifError( 486 | query.validate({ order: ['thing', 'DESC'] }).error 487 | , 'applies `order` to validate.query' 488 | ); 489 | 490 | t.truthy( 491 | query.validate({ notAThing: true }).error 492 | , 'errors on a non-valid key' 493 | ); 494 | }); 495 | }); 496 | 497 | test('validate.query restrictMethods w/ config as plain object', (t) => { 498 | const config = { 499 | validate: { 500 | query: { 501 | aKey: joi.boolean(), 502 | }, 503 | }, 504 | }; 505 | 506 | restrictMethods.forEach((method) => { 507 | const configForMethod = getConfigForMethod({ 508 | method, 509 | config, 510 | }); 511 | const { query } = configForMethod.validate; 512 | 513 | const keys = [ 514 | ...Object.keys(config.validate.query), 515 | ]; 516 | 517 | keys.forEach((key) => { 518 | t.ifError( 519 | query.validate({ [key]: true }).error 520 | , `applies ${key} to validate.query` 521 | ); 522 | }); 523 | 524 | t.truthy( 525 | query.validate({ notAThing: true }).error 526 | , 'errors on a non-valid key' 527 | ); 528 | }); 529 | }); 530 | 531 | test('validate.query restrictMethods w/ config as joi object', (t) => { 532 | const queryKeys = { 533 | aKey: joi.boolean(), 534 | }; 535 | const config = { 536 | validate: { 537 | query: joi.object().keys(queryKeys), 538 | }, 539 | }; 540 | 541 | whereMethods.forEach((method) => { 542 | const configForMethod = getConfigForMethod({ 543 | method, 544 | config, 545 | }); 546 | const { query } = configForMethod.validate; 547 | 548 | const keys = [ 549 | ...Object.keys(queryKeys), 550 | ]; 551 | 552 | keys.forEach((key) => { 553 | t.ifError( 554 | query.validate({ [key]: true }).error 555 | , `applies ${key} to validate.query` 556 | ); 557 | }); 558 | 559 | t.truthy( 560 | query.validate({ notAThing: true }).error 561 | , 'errors on a non-valid key' 562 | ); 563 | }); 564 | }); 565 | 566 | 567 | test('does not modify initial config on multiple passes', (t) => { 568 | const { config } = t.context; 569 | const originalConfig = { ...config }; 570 | 571 | whereMethods.forEach((method) => { 572 | getConfigForMethod({ method, ...t.context }); 573 | }); 574 | 575 | t.deepEqual( 576 | config 577 | , originalConfig 578 | , 'does not modify the original config object' 579 | ); 580 | }); 581 | --------------------------------------------------------------------------------