Compare commits
30 Commits
Author | SHA1 | Date | |
---|---|---|---|
34e37217f1 | |||
6a80149916 | |||
cb6ea51836 | |||
5aec1242db | |||
8fb3f2e849 | |||
11e6ff596c | |||
6a2290f064 | |||
1daa68e03e | |||
01081db7a3 | |||
3b962ce4d8 | |||
f638680e29 | |||
94e9870133 | |||
0713f81301 | |||
f49e4daf79 | |||
087e64607c | |||
57f95f8c95 | |||
10d108878a | |||
eebf7b91f0 | |||
a45a3ab317 | |||
7a8cd26dc8 | |||
80d0a74c82 | |||
863aa1d98b | |||
90f72cb07a | |||
d3976fa44b | |||
966b35164f | |||
548a6ecd98 | |||
be993eda40 | |||
bcb7861061 | |||
07176018b7 | |||
83eadf0929 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -36,3 +36,6 @@ npm-debug.log
|
||||
|
||||
# System
|
||||
.DS_Store
|
||||
|
||||
coverage.lcov
|
||||
.nyc_output
|
||||
|
14
README.md
14
README.md
@ -109,7 +109,7 @@ Getting related models is easy, just use a query parameter `include`.
|
||||
|
||||
```js
|
||||
// returns all teams with their related City model
|
||||
// GET /teams?include=City
|
||||
// GET /teams?include=city
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({include: City})
|
||||
@ -118,12 +118,21 @@ Team.findAll({include: City})
|
||||
If you want to get multiple related models, just pass multiple `include` parameters.
|
||||
```js
|
||||
// returns all teams with their related City and Uniform models
|
||||
// GET /teams?include=City&include=Uniform
|
||||
// GET /teams?include[]=city&include[]=uniform
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({include: [City, Uniform]})
|
||||
```
|
||||
|
||||
For models that have a many-to-many relationship, you can also pass the plural version of the association.
|
||||
```js
|
||||
// returns all teams with their related City and Uniform models
|
||||
// GET /teams?include=players
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({include: [Player]})
|
||||
```
|
||||
|
||||
## `limit` and `offset` queries
|
||||
Restricting list (`GET`) and scope queries to a restricted count can be done by passing `limit=<number>` and/or `offset=<number>`.
|
||||
|
||||
@ -149,6 +158,7 @@ Team.findAll({order: ['name']})
|
||||
```js
|
||||
// returns the teams ordered by the name column, descending
|
||||
// GET /teams?order[0]=name&order[0]=DESC
|
||||
// GET /teams?order=name%20DESC
|
||||
|
||||
// results in a Sequelize query:
|
||||
Team.findAll({order: [['name', 'DESC']]})
|
||||
|
@ -1,9 +1,13 @@
|
||||
machine:
|
||||
node:
|
||||
version: 6.5.0
|
||||
version: 6.9.0
|
||||
|
||||
dependencies:
|
||||
pre:
|
||||
- npm prune
|
||||
post:
|
||||
- mkdir -p $CIRCLE_TEST_REPORTS/ava
|
||||
|
||||
test:
|
||||
post:
|
||||
- npm run coverage
|
||||
|
35
package.json
35
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hapi-sequelize-crud",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"description": "Hapi plugin that automatically generates RESTful API for CRUD",
|
||||
"main": "build/index.js",
|
||||
"config": {
|
||||
@ -10,10 +10,11 @@
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src",
|
||||
"test": "ava --require babel-register --source='src/**/*.js' --source='!build/**/*' --tap=${CI-false} src/**/*.test.js | $(if [ -z ${CI:-} ]; then echo 'tail'; else tap-xunit > $CIRCLE_TEST_REPORTS/ava/ava.xml; fi;)",
|
||||
"tdd": "ava --require babel-register --source='src/**/*.js' --source='!build/**/*' --watch src/**/*.test.js",
|
||||
"build": "scripty",
|
||||
"watch": "scripty"
|
||||
"test": "SCRIPTY_SILENT=true scripty",
|
||||
"coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov",
|
||||
"tdd": "ava --watch",
|
||||
"build": "SCRIPTY_SILENT=true scripty",
|
||||
"watch": "SCRIPTY_SILENT=true scripty"
|
||||
},
|
||||
"repository": {
|
||||
"git": "https://github.com/mdibaiee/hapi-sequelize-crud"
|
||||
@ -32,13 +33,21 @@
|
||||
"babel-plugin-transform-es2015-modules-commonjs": "^6.16.0",
|
||||
"babel-preset-stage-1": "^6.16.0",
|
||||
"babel-register": "^6.16.3",
|
||||
"bluebird": "^3.4.6",
|
||||
"codecov": "^1.0.1",
|
||||
"eslint": "^3.8.1",
|
||||
"eslint-config-pichak": "^1.1.2",
|
||||
"eslint-plugin-ava": "^3.1.1",
|
||||
"ghooks": "^1.3.2",
|
||||
"hapi": "^15.2.0",
|
||||
"hapi-sequelize": "^3.0.4",
|
||||
"nyc": "^8.3.2",
|
||||
"portfinder": "^1.0.9",
|
||||
"scripty": "^1.6.0",
|
||||
"sequelize": "^3.24.6",
|
||||
"sinon": "^1.17.6",
|
||||
"sinon-bluebird": "^3.1.0",
|
||||
"sqlite3": "^3.1.7",
|
||||
"tap-xunit": "^1.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -48,5 +57,21 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"babel-polyfill": "^6.13.0"
|
||||
},
|
||||
"nyc": {
|
||||
"cache": true
|
||||
},
|
||||
"ava": {
|
||||
"source": [
|
||||
"src/**/*.js",
|
||||
"!build/**/*"
|
||||
],
|
||||
"files": [
|
||||
"**/*.test.js",
|
||||
"!build/**/*"
|
||||
],
|
||||
"require": [
|
||||
"babel-register"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
14
scripts/test.sh
Executable file
14
scripts/test.sh
Executable file
@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# strict mode http://redsymbol.net/articles/unofficial-bash-strict-mode/
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
nyc=./node_modules/.bin/nyc
|
||||
ava=./node_modules/.bin/ava
|
||||
|
||||
if [ ! -z ${CI:-} ]; then
|
||||
$nyc $ava --tap=${CI-false} | tap-xunit > $CIRCLE_TEST_REPORTS/ava/ava.xml
|
||||
else
|
||||
$nyc $ava
|
||||
fi
|
||||
|
44
src/crud-create.integration.test.js
Normal file
44
src/crud-create.integration.test.js
Normal file
@ -0,0 +1,44 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
const STATUS_BAD_REQUEST = 400;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('where /player {name: "Chard"}', async (t) => {
|
||||
const { server, sequelize: { models: { Player } } } = t.context;
|
||||
const url = '/player';
|
||||
const method = 'POST';
|
||||
const payload = { name: 'Chard' };
|
||||
|
||||
const notPresentPlayer = await Player.findOne({ where: payload });
|
||||
t.falsy(notPresentPlayer);
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method, payload });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.truthy(result.id);
|
||||
t.is(result.name, payload.name);
|
||||
});
|
||||
|
||||
test('not found /notamodel {name: "Chard"}', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/notamodel';
|
||||
const method = 'POST';
|
||||
const payload = { name: 'Chard' };
|
||||
|
||||
const { statusCode } = await server.inject({ url, method, payload });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
||||
|
||||
|
||||
test('no payload /player/1', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/player';
|
||||
const method = 'POST';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_BAD_REQUEST);
|
||||
});
|
177
src/crud-destroy.integration.test.js
Normal file
177
src/crud-destroy.integration.test.js
Normal file
@ -0,0 +1,177 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
const STATUS_BAD_REQUEST = 400;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('destroy where /player?name=Baseball', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = `/player?name=${player1.name}`;
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayer = await Player.findById(player1.id);
|
||||
t.truthy(presentPlayer);
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, player1.id);
|
||||
|
||||
const deletedPlayer = await Player.findById(player1.id);
|
||||
t.falsy(deletedPlayer);
|
||||
const stillTherePlayer = await Player.findById(player2.id);
|
||||
t.truthy(stillTherePlayer);
|
||||
});
|
||||
|
||||
test('destroyAll where /players?name=Baseball', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = `/players?name=${player1.name}`;
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayer = await Player.findById(player1.id);
|
||||
t.truthy(presentPlayer);
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, player1.id);
|
||||
|
||||
const deletedPlayer = await Player.findById(player1.id);
|
||||
t.falsy(deletedPlayer);
|
||||
const stillTherePlayer = await Player.findById(player2.id);
|
||||
t.truthy(stillTherePlayer);
|
||||
});
|
||||
|
||||
test('destroyAll /players', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = '/players';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
const resultPlayerIds = result.map(({ id }) => id);
|
||||
t.truthy(resultPlayerIds.includes(player1.id));
|
||||
t.truthy(resultPlayerIds.includes(player2.id));
|
||||
|
||||
const deletedPlayers = await Player.findAll();
|
||||
t.is(deletedPlayers.length, 0);
|
||||
});
|
||||
|
||||
test('destroy not found /player/10', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/player/10';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
|
||||
const nonDeletedPlayers = await Player.findAll();
|
||||
t.is(nonDeletedPlayers.length, presentPlayers.length);
|
||||
});
|
||||
|
||||
test('destroyAll not found /players?name=no', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/players?name=no';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
|
||||
const nonDeletedPlayers = await Player.findAll();
|
||||
t.is(nonDeletedPlayers.length, presentPlayers.length);
|
||||
});
|
||||
|
||||
test('not found /notamodel', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/notamodel';
|
||||
const method = 'DELETE';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('destroyScope /players/returnsOne', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/players/returnsOne';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, player1.id);
|
||||
|
||||
const nonDeletedPlayers = await Player.findAll();
|
||||
t.is(nonDeletedPlayers.length, presentPlayers.length - 1);
|
||||
});
|
||||
|
||||
test('destroyScope /players/returnsNone', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/players/returnsNone';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
|
||||
const nonDeletedPlayers = await Player.findAll();
|
||||
const nonDeletedPlayerIds = nonDeletedPlayers.map(({ id }) => id);
|
||||
t.truthy(nonDeletedPlayerIds.includes(player1.id));
|
||||
t.truthy(nonDeletedPlayerIds.includes(player2.id));
|
||||
});
|
||||
|
||||
test('destroyScope invalid scope /players/invalid', async (t) => {
|
||||
const { server, instances, sequelize: { models: { Player } } } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/players/invalid';
|
||||
const method = 'DELETE';
|
||||
|
||||
const presentPlayers = await Player.findAll();
|
||||
const playerIds = presentPlayers.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_BAD_REQUEST);
|
||||
|
||||
const nonDeletedPlayers = await Player.findAll();
|
||||
const nonDeletedPlayerIds = nonDeletedPlayers.map(({ id }) => id);
|
||||
t.truthy(nonDeletedPlayerIds.includes(player1.id));
|
||||
t.truthy(nonDeletedPlayerIds.includes(player2.id));
|
||||
});
|
87
src/crud-include.integration.test.js
Normal file
87
src/crud-include.integration.test.js
Normal file
@ -0,0 +1,87 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('belongsTo /team?include=city', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1, city1 } = instances;
|
||||
const path = `/team/${team1.id}?include=city`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, team1.id);
|
||||
t.is(result.City.id, city1.id);
|
||||
});
|
||||
|
||||
test('belongsTo /team?include=cities', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1, city1 } = instances;
|
||||
const path = `/team/${team1.id}?include=cities`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, team1.id);
|
||||
t.is(result.City.id, city1.id);
|
||||
});
|
||||
|
||||
test('hasMany /team?include=player', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1, player1, player2 } = instances;
|
||||
const path = `/team/${team1.id}?include=player`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, team1.id);
|
||||
|
||||
const playerIds = result.Players.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
});
|
||||
|
||||
test('hasMany /team?include=players', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1, player1, player2 } = instances;
|
||||
const path = `/team/${team1.id}?include=players`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, team1.id);
|
||||
|
||||
const playerIds = result.Players.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
});
|
||||
|
||||
test('multiple includes /team?include=players&include=city', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1, player1, player2, city1 } = instances;
|
||||
const path = `/team/${team1.id}?include=players&include=city`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, team1.id);
|
||||
|
||||
const playerIds = result.Players.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
t.is(result.City.id, city1.id);
|
||||
});
|
||||
|
||||
test('multiple includes /team?include[]=players&include[]=city', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1, player1, player2, city1 } = instances;
|
||||
const path = `/team/${team1.id}?include[]=players&include[]=city`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, team1.id);
|
||||
|
||||
const playerIds = result.Players.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
t.is(result.City.id, city1.id);
|
||||
});
|
83
src/crud-list-order.integration.test.js
Normal file
83
src/crud-list-order.integration.test.js
Normal file
@ -0,0 +1,83 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_BAD_QUERY = 502;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('/players?order=name', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = '/players?order=name';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player1.name);
|
||||
t.is(result[1].name, player2.name);
|
||||
});
|
||||
|
||||
test('/players?order=name%20ASC', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = '/players?order=name%20ASC';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player1.name);
|
||||
t.is(result[1].name, player2.name);
|
||||
});
|
||||
|
||||
test('/players?order=name%20DESC', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = '/players?order=name%20DESC';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player2.name);
|
||||
t.is(result[1].name, player1.name);
|
||||
});
|
||||
|
||||
test('/players?order[]=name', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = '/players?order[]=name';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player1.name);
|
||||
t.is(result[1].name, player2.name);
|
||||
});
|
||||
|
||||
test('/players?order[0]=name&order[0]=DESC', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1, player2 } = instances;
|
||||
const url = '/players?order[0]=name&order[0]=DESC';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
// this is the order we'd expect the names to be in
|
||||
t.is(result[0].name, player2.name);
|
||||
t.is(result[1].name, player1.name);
|
||||
});
|
||||
|
||||
test('invalid column /players?order[0]=invalid', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/players?order[]=invalid';
|
||||
const method = 'GET';
|
||||
|
||||
const { statusCode, result } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_BAD_QUERY);
|
||||
t.truthy(result.message.includes('invalid'));
|
||||
});
|
26
src/crud-route-creation.integration.test.js
Normal file
26
src/crud-route-creation.integration.test.js
Normal file
@ -0,0 +1,26 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const { modelNames } = setup(test);
|
||||
|
||||
const confirmRoute = (t, { path, method }) => {
|
||||
const { server } = t.context;
|
||||
// there's only one connection, so just get the first table
|
||||
const routes = server.table()[0].table;
|
||||
|
||||
t.truthy(routes.find((route) => {
|
||||
return route.path = path
|
||||
&& route.method === method;
|
||||
}));
|
||||
};
|
||||
|
||||
modelNames.forEach(({ singular, plural }) => {
|
||||
test('get', confirmRoute, { path: `/${singular}/{id}`, method: 'get' });
|
||||
test('list', confirmRoute, { path: `/${plural}/{id}`, method: 'get' });
|
||||
test('scope', confirmRoute, { path: `/${plural}/{scope}`, method: 'get' });
|
||||
test('create', confirmRoute, { path: `/${singular}`, method: 'post' });
|
||||
test('destroy', confirmRoute, { path: `/${plural}`, method: 'delete' });
|
||||
test('destroyScope', confirmRoute, { path: `/${plural}/{scope}`, method: 'delete' });
|
||||
test('update', confirmRoute, { path: `/${singular}/{id}`, method: 'put' });
|
||||
});
|
40
src/crud-scope.integration.test.js
Normal file
40
src/crud-scope.integration.test.js
Normal file
@ -0,0 +1,40 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
const STATUS_BAD_REQUEST = 400;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('/players/returnsOne', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1 } = instances;
|
||||
const url = '/players/returnsOne';
|
||||
const method = 'GET';
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.length, 1);
|
||||
t.truthy(result[0].id, player1.id);
|
||||
});
|
||||
|
||||
test('/players/returnsNone', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/players/returnsNone';
|
||||
const method = 'GET';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('invalid scope /players/invalid', async (t) => {
|
||||
const { server } = t.context;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/players/invalid';
|
||||
const method = 'GET';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_BAD_REQUEST);
|
||||
});
|
54
src/crud-update.integration.test.js
Normal file
54
src/crud-update.integration.test.js
Normal file
@ -0,0 +1,54 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
const STATUS_BAD_REQUEST = 400;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('where /player/1 {name: "Chard"}', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1 } = instances;
|
||||
const url = `/player/${player1.id}`;
|
||||
const method = 'PUT';
|
||||
const payload = { name: 'Chard' };
|
||||
|
||||
const { result, statusCode } = await server.inject({ url, method, payload });
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, player1.id);
|
||||
t.is(result.name, payload.name);
|
||||
});
|
||||
|
||||
test('not found /player/10 {name: "Chard"}', async (t) => {
|
||||
const { server } = t.context;
|
||||
// this doesn't exist in our fixtures
|
||||
const url = '/player/10';
|
||||
const method = 'PUT';
|
||||
const payload = { name: 'Chard' };
|
||||
|
||||
const { statusCode } = await server.inject({ url, method, payload });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
||||
|
||||
|
||||
test('no payload /player/1', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { player1 } = instances;
|
||||
const url = `/player/${player1.id}`;
|
||||
const method = 'PUT';
|
||||
|
||||
const { statusCode } = await server.inject({ url, method });
|
||||
t.is(statusCode, STATUS_BAD_REQUEST);
|
||||
});
|
||||
|
||||
test('not found /notamodel {name: "Chard"}', async (t) => {
|
||||
const { server } = t.context;
|
||||
const url = '/notamodel';
|
||||
const method = 'PUT';
|
||||
const payload = { name: 'Chard' };
|
||||
|
||||
const { statusCode } = await server.inject({ url, method, payload });
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
53
src/crud-where.integration.test.js
Normal file
53
src/crud-where.integration.test.js
Normal file
@ -0,0 +1,53 @@
|
||||
import test from 'ava';
|
||||
import 'sinon-bluebird';
|
||||
import setup from '../test/integration-setup.js';
|
||||
|
||||
const STATUS_OK = 200;
|
||||
const STATUS_NOT_FOUND = 404;
|
||||
|
||||
setup(test);
|
||||
|
||||
test('single result /team?name=Baseball', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1 } = instances;
|
||||
const path = `/team?name=${team1.name}`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, team1.id);
|
||||
t.is(result.name, team1.name);
|
||||
});
|
||||
|
||||
test('no results /team?name=Baseball&id=2', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1 } = instances;
|
||||
// this doesn't exist in our fixtures
|
||||
const path = `/team?name=${team1.name}&id=2`;
|
||||
|
||||
const { statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('single result from list query /teams?name=Baseball', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1 } = instances;
|
||||
const path = `/team?name=${team1.name}`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
t.is(result.id, team1.id);
|
||||
t.is(result.name, team1.name);
|
||||
});
|
||||
|
||||
test('multiple results from list query /players?teamId=1', async (t) => {
|
||||
const { server, instances } = t.context;
|
||||
const { team1, player1, player2 } = instances;
|
||||
const path = `/players?teamId=${team1.id}`;
|
||||
|
||||
const { result, statusCode } = await server.inject(path);
|
||||
t.is(statusCode, STATUS_OK);
|
||||
const playerIds = result.map(({ id }) => id);
|
||||
t.truthy(playerIds.includes(player1.id));
|
||||
t.truthy(playerIds.includes(player2.id));
|
||||
});
|
||||
|
38
src/crud.js
38
src/crud.js
@ -55,15 +55,27 @@ models: {
|
||||
export default (server, model, { prefix, defaultConfig: config, models: permissions }) => {
|
||||
const modelName = model._singular;
|
||||
const modelAttributes = Object.keys(model.attributes);
|
||||
const modelAssociations = Object.keys(model.associations);
|
||||
const associatedModelNames = Object.keys(model.associations);
|
||||
const modelAssociations = [
|
||||
...associatedModelNames,
|
||||
..._.flatMap(associatedModelNames, (associationName) => {
|
||||
const { target } = model.associations[associationName];
|
||||
const { _singular, _plural, _Singular, _Plural } = target;
|
||||
return [_singular, _plural, _Singular, _Plural];
|
||||
}),
|
||||
].filter(Boolean);
|
||||
|
||||
const attributeValidation = modelAttributes.reduce((params, attribute) => {
|
||||
// TODO: use joi-sequelize
|
||||
params[attribute] = joi.any();
|
||||
return params;
|
||||
}, {});
|
||||
|
||||
const validAssociations = modelAssociations.length
|
||||
? joi.string().valid(...modelAssociations)
|
||||
: joi.valid(null);
|
||||
const associationValidation = {
|
||||
include: joi.array().items(joi.string().valid(...modelAssociations)),
|
||||
include: [joi.array().items(validAssociations), validAssociations],
|
||||
};
|
||||
|
||||
const scopes = Object.keys(model.options.scopes);
|
||||
@ -202,6 +214,8 @@ export const scope = ({ server, model, prefix = '/', config }) => {
|
||||
include, where, limit, offset, order,
|
||||
});
|
||||
|
||||
if (!list.length) return void reply(notFound('Nothing found.'));
|
||||
|
||||
reply(list.map((item) => item.toJSON()));
|
||||
},
|
||||
config,
|
||||
@ -232,10 +246,18 @@ export const destroy = ({ server, model, prefix = '/', config }) => {
|
||||
@error
|
||||
async handler(request, reply) {
|
||||
const where = parseWhere(request);
|
||||
if (request.params.id) where[model.primaryKeyField] = request.params.id;
|
||||
const { id } = request.params;
|
||||
if (id) where[model.primaryKeyField] = id;
|
||||
|
||||
const list = await model.findAll({ where });
|
||||
|
||||
if (!list.length) {
|
||||
return void reply(id
|
||||
? notFound(`${id} not found.`)
|
||||
: notFound('Nothing found.')
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(list.map(instance => instance.destroy()));
|
||||
|
||||
const listAsJSON = list.map((item) => item.toJSON());
|
||||
@ -254,9 +276,17 @@ export const destroyAll = ({ server, model, prefix = '/', config }) => {
|
||||
@error
|
||||
async handler(request, reply) {
|
||||
const where = parseWhere(request);
|
||||
const { id } = request.params;
|
||||
|
||||
const list = await model.findAll({ where });
|
||||
|
||||
if (!list.length) {
|
||||
return void reply(id
|
||||
? notFound(`${id} not found.`)
|
||||
: notFound('Nothing found.')
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(list.map(instance => instance.destroy()));
|
||||
|
||||
const listAsJSON = list.map((item) => item.toJSON());
|
||||
@ -281,6 +311,8 @@ export const destroyScope = ({ server, model, prefix = '/', config }) => {
|
||||
|
||||
const list = await model.scope(request.params.scope).findAll({ include, where });
|
||||
|
||||
if (!list.length) return void reply(notFound('Nothing found.'));
|
||||
|
||||
await Promise.all(list.map(instance => instance.destroy()));
|
||||
|
||||
const listAsJSON = list.map((item) => item.toJSON());
|
||||
|
@ -225,7 +225,7 @@ test('crud#list handler with order', async (t) => {
|
||||
|
||||
t.deepEqual(
|
||||
findAllArgs.order,
|
||||
[request.query.order],
|
||||
[[request.query.order]],
|
||||
'queries with the order as an array b/c that\'s what sequelize wants'
|
||||
);
|
||||
});
|
||||
|
@ -144,7 +144,7 @@ export default ({
|
||||
.keys({
|
||||
limit: joi.number().min(0).integer(),
|
||||
offset: joi.number().min(0).integer(),
|
||||
order: joi.array(),
|
||||
order: [joi.array(), joi.string()],
|
||||
}),
|
||||
get(methodConfig, 'validate.query')
|
||||
);
|
||||
|
12
src/index.js
12
src/index.js
@ -7,7 +7,7 @@ import url from 'url';
|
||||
import qs from 'qs';
|
||||
|
||||
const register = (server, options = {}, next) => {
|
||||
options.prefix = options.prefix || '';
|
||||
options.prefix = options.prefix || '/';
|
||||
options.name = options.name || 'db';
|
||||
|
||||
const db = server.plugins['hapi-sequelize'][options.name];
|
||||
@ -32,11 +32,12 @@ const register = (server, options = {}, next) => {
|
||||
const { plural, singular } = model.options.name;
|
||||
model._plural = plural.toLowerCase();
|
||||
model._singular = singular.toLowerCase();
|
||||
model._Plural = plural;
|
||||
model._Singular = singular;
|
||||
|
||||
// Join tables
|
||||
if (model.options.name.singular !== model.name) continue;
|
||||
|
||||
crud(server, model, options);
|
||||
|
||||
for (const key of Object.keys(model.associations)) {
|
||||
const association = model.associations[key];
|
||||
@ -92,6 +93,13 @@ const register = (server, options = {}, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// build the methods for each model now that we've defined all the
|
||||
// associations
|
||||
Object.keys(models).forEach((modelName) => {
|
||||
const model = models[modelName];
|
||||
crud(server, model, options);
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
|
11
src/utils.js
11
src/utils.js
@ -20,6 +20,13 @@ export const parseInclude = request => {
|
||||
const { models } = noGetDb ? request : request.getDb();
|
||||
|
||||
return include.map(a => {
|
||||
const singluarOrPluralMatch = Object.keys(models).find((modelName) => {
|
||||
const { _singular, _plural } = models[modelName];
|
||||
return _singular === a || _plural === a;
|
||||
});
|
||||
|
||||
if (singluarOrPluralMatch) return models[singluarOrPluralMatch];
|
||||
|
||||
if (typeof a === 'string') return models[a];
|
||||
|
||||
if (a && typeof a.model === 'string' && a.model.length) {
|
||||
@ -63,7 +70,7 @@ export const parseOrder = (request) => {
|
||||
|
||||
// transform to an array so sequelize will escape the input for us and
|
||||
// maintain security. See http://docs.sequelizejs.com/en/latest/docs/querying/#ordering
|
||||
if (isString(order)) return order.split(' ');
|
||||
if (isString(order)) return [order.split(' ')];
|
||||
|
||||
for (const key of Object.keys(order)) {
|
||||
try {
|
||||
@ -73,7 +80,7 @@ export const parseOrder = (request) => {
|
||||
}
|
||||
}
|
||||
|
||||
return order;
|
||||
return [order];
|
||||
};
|
||||
|
||||
export const getMethod = (model, association, plural = true, method = 'get') => {
|
||||
|
@ -57,7 +57,7 @@ test('parseOrder returns order when a string', (t) => {
|
||||
|
||||
t.deepEqual(
|
||||
parseOrder(request)
|
||||
, [order]
|
||||
, [[order]]
|
||||
);
|
||||
});
|
||||
|
||||
@ -69,7 +69,7 @@ test('parseOrder returns order when json', (t) => {
|
||||
|
||||
t.deepEqual(
|
||||
parseOrder(request)
|
||||
, order
|
||||
, [order]
|
||||
);
|
||||
});
|
||||
|
||||
|
18
test/fixtures/models/city.js
vendored
Normal file
18
test/fixtures/models/city.js
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
export default (sequelize, DataTypes) => {
|
||||
return sequelize.define('City', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
}, {
|
||||
classMethods: {
|
||||
associate: (models) => {
|
||||
models.City.hasMany(models.Team, {
|
||||
foreignKey: { name: 'cityId' },
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
32
test/fixtures/models/player.js
vendored
Normal file
32
test/fixtures/models/player.js
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
export default (sequelize, DataTypes) => {
|
||||
return sequelize.define('Player', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
teamId: DataTypes.INTEGER,
|
||||
active: DataTypes.BOOLEAN,
|
||||
}, {
|
||||
classMethods: {
|
||||
associate: (models) => {
|
||||
models.Player.belongsTo(models.Team, {
|
||||
foreignKey: { name: 'teamId' },
|
||||
});
|
||||
},
|
||||
},
|
||||
scopes: {
|
||||
returnsOne: {
|
||||
where: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
returnsNone: {
|
||||
where: {
|
||||
name: 'notaname',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
22
test/fixtures/models/team.js
vendored
Normal file
22
test/fixtures/models/team.js
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
export default (sequelize, DataTypes) => {
|
||||
return sequelize.define('Team', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
cityId: DataTypes.INTEGER,
|
||||
}, {
|
||||
classMethods: {
|
||||
associate: (models) => {
|
||||
models.Team.belongsTo(models.City, {
|
||||
foreignKey: { name: 'cityId' },
|
||||
});
|
||||
models.Team.hasMany(models.Player, {
|
||||
foreignKey: { name: 'teamId' },
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
72
test/integration-setup.js
Normal file
72
test/integration-setup.js
Normal file
@ -0,0 +1,72 @@
|
||||
import hapi from 'hapi';
|
||||
import Sequelize from 'sequelize';
|
||||
import portfinder from 'portfinder';
|
||||
import path from 'path';
|
||||
import Promise from 'bluebird';
|
||||
|
||||
const getPort = Promise.promisify(portfinder.getPort);
|
||||
const modelsPath = path.join(__dirname, 'fixtures', 'models');
|
||||
const modelsGlob = path.join(modelsPath, '**', '*.js');
|
||||
const dbName = 'db';
|
||||
|
||||
// these are what's in the fixtures dir
|
||||
const modelNames = [
|
||||
{ Singluar: 'City', singular: 'city', Plural: 'Cities', plural: 'cities' },
|
||||
{ Singluar: 'Team', singular: 'team', Plural: 'Teams', plural: 'teams' },
|
||||
{ Singluar: 'Player', singular: 'player', Plural: 'Players', plural: 'players' },
|
||||
];
|
||||
|
||||
|
||||
export default (test) => {
|
||||
test.beforeEach('get an open port', async (t) => {
|
||||
t.context.port = await getPort();
|
||||
});
|
||||
|
||||
test.beforeEach('setup server', async (t) => {
|
||||
const sequelize = t.context.sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
logging: false,
|
||||
});
|
||||
|
||||
const server = t.context.server = new hapi.Server();
|
||||
server.connection({
|
||||
host: '0.0.0.0',
|
||||
port: t.context.port,
|
||||
});
|
||||
|
||||
await server.register({
|
||||
register: require('hapi-sequelize'),
|
||||
options: {
|
||||
name: dbName,
|
||||
models: [modelsGlob],
|
||||
sequelize,
|
||||
sync: true,
|
||||
forceSync: true,
|
||||
},
|
||||
});
|
||||
|
||||
await server.register({
|
||||
register: require('../src/index.js'),
|
||||
options: {
|
||||
name: dbName,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach('create data', async (t) => {
|
||||
const { Player, Team, City } = t.context.sequelize.models;
|
||||
const city1 = await City.create({ name: 'Healdsburg' });
|
||||
const team1 = await Team.create({ name: 'Baseballs', cityId: city1.id });
|
||||
const player1 = await Player.create({
|
||||
name: 'Pinot', teamId: team1.id, active: true,
|
||||
});
|
||||
const player2 = await Player.create({ name: 'Syrah', teamId: team1.id });
|
||||
t.context.instances = { city1, team1, player1, player2 };
|
||||
});
|
||||
|
||||
// kill the server so that we can exit and don't leak memory
|
||||
test.afterEach('stop the server', (t) => t.context.server.stop());
|
||||
|
||||
return { modelNames };
|
||||
};
|
Reference in New Issue
Block a user