Compare commits

...

122 Commits

Author SHA1 Message Date
Joey Baker
facde8d542
Merge pull request #39 from rithalnz/patch-1
Create LICENSE
2021-03-13 11:35:24 -08:00
rithalnz
0cbc28ef90
Create LICENSE
Added MIT License
2021-03-10 12:49:07 -08:00
Mahdi Dibaiee
a6590a9650 fix(crud): see 83eadf0929a2506541e34b4a6f73d46bfa2404d3 and #37 2017-02-04 22:18:22 +03:30
Mahdi Dibaiee
85b52fd5ab fix(crud): should ignore JoinTable models while creating the routes -
solves #37
2017-02-04 22:11:58 +03:30
Joey Baker
5ba9d7d261 2.9.1 2016-11-01 19:10:04 -07:00
Joey Baker
07837ef36c Merge pull request #33 from mdibaiee/add-limit-offset-tests
Add integration tests for limit/offset
2016-11-01 19:09:45 -07:00
Joey Baker
25501bbb10 Test add integration tests for limit/offset
#28
2016-11-01 17:39:35 -07:00
Joey Baker
a335471f02 Fix(crud/list) 404 on no results 2016-11-01 17:39:16 -07:00
Joey Baker
ce26814f74 Test add a returnsAll scope to player fixture 2016-11-01 17:38:55 -07:00
Joey Baker
d1fc6d46e8 2.9.0 2016-10-31 14:24:12 -07:00
Joey Baker
4e94c7f825 Merge pull request #32 from mdibaiee/more-order-integration
Feat ordering by associated models now works
2016-10-31 14:23:43 -07:00
Joey Baker
c289fb2ed4 Feat ordering by associated models now works
It's now possible to order by associated models. This technically might
have worked before b/c we were parsing JSON sent to `order`, but I'm
pretty sure it wouldn't actually work b/c we never grabbed the actual
model to associate by. Regardless, this actually enables things and adds
tests to prove it.

Note: there's a sequelize bug that's poorly reported but definitely
known where `order` with associated models can fail because the sql
generated doesn't include a join. So, I added docs noting that and a
`test.failing` so that we'll be notified when that bug is fixed and can
remove the note.
2016-10-31 12:48:34 -07:00
Joey Baker
e1b851f932 Test: add a second mock team
So that we can test complex sorts
2016-10-31 12:41:34 -07:00
Joey Baker
34e37217f1 2.8.0 2016-10-28 11:28:23 -07:00
Joey Baker
6a80149916 Merge pull request #31 from mdibaiee/code-coverage
Add more integration tests
2016-10-28 11:28:06 -07:00
Joey Baker
cb6ea51836 Test add integration tests for scope 2016-10-28 11:22:52 -07:00
Joey Baker
5aec1242db Test add integration tests for ordering lists 2016-10-28 11:22:38 -07:00
Joey Baker
8fb3f2e849 Fix(crud) actually enable multiple orders
This was supposed to work, but adding integration tests I realized it
didn't. #oops
2016-10-28 11:22:05 -07:00
Joey Baker
11e6ff596c Fix(crud) scope now 404s on no results 2016-10-28 11:20:59 -07:00
Joey Baker
6a2290f064 Docs add docs for additional order option 2016-10-28 11:20:12 -07:00
Joey Baker
1daa68e03e Fix(crud) destroyScope sends 404 when not found 2016-10-27 21:03:57 -07:00
Joey Baker
01081db7a3 Test add destroyScope tests 2016-10-27 21:03:32 -07:00
Joey Baker
3b962ce4d8 2.7.3 2016-10-27 13:20:46 -07:00
Joey Baker
f638680e29 Merge pull request #30 from mdibaiee/code-coverage
Add integration tests
2016-10-27 13:20:18 -07:00
Joey Baker
94e9870133 Fix(crud) 404 errors for destroy and destroyAll 2016-10-27 12:33:31 -07:00
Joey Baker
0713f81301 Test add CRUD tests
boosting our test coverage
2016-10-27 12:33:02 -07:00
Joey Baker
f49e4daf79 Test fix error checking in include tests #oops 2016-10-27 12:32:36 -07:00
Joey Baker
087e64607c Merge pull request #29 from mdibaiee/code-coverage
Add code coverage
2016-10-26 18:14:37 -07:00
Joey Baker
57f95f8c95 Test(CI) setup code coverage
Also moves ava config to package.json
2016-10-26 18:10:32 -07:00
Joey Baker
10d108878a Chore(deps) install codecov and nyc 2016-10-26 17:24:59 -07:00
Joey Baker
eebf7b91f0 2.7.2 2016-10-26 14:09:42 -07:00
Joey Baker
a45a3ab317 Merge pull request #27 from mdibaiee/add-integration-tests
Add integration tests
2016-10-26 14:08:38 -07:00
Joey Baker
7a8cd26dc8 Test add integration tests for ?include 2016-10-26 13:27:13 -07:00
Joey Baker
80d0a74c82 Test add integration tests for route creation 2016-10-26 13:26:56 -07:00
Joey Baker
863aa1d98b Test add fixtures and integration setup 2016-10-26 13:26:39 -07:00
Joey Baker
90f72cb07a Fix(crud) models w/o associations validation 2016-10-26 13:26:15 -07:00
Joey Baker
d3976fa44b Fix prefix should default to /, not ''
b/c `route` isn't a valid Hapi route, but `/route` is.
2016-10-26 13:25:27 -07:00
Joey Baker
966b35164f Chore(deps) install dev deps for integration tests 2016-10-26 13:24:27 -07:00
Joey Baker
548a6ecd98 2.7.1 2016-10-26 11:43:53 -07:00
Joey Baker
be993eda40 Merge pull request #26 from mdibaiee/fix-include
Fix(crud) include param lookup now works w/plurals
2016-10-26 11:42:15 -07:00
Joey Baker
bcb7861061 Fix(crud) include param lookup now works w/plurals
Previously, {one,many}-to-many relationships with models would result in
`associationNames` that were plural. e.g. `Team` might have many
players and one location. The validation was expecting to see the plural
`Players` and the singular `Location` but Sequelize is expecting the
singular `Player` (`Location` worked fine). This meant that include
lookups would silently fail. This fixes the problem in a backward-
compatible way.

It continues to allow `include=Location` (capitalized) for backward-
compatibility. And now allows and actually does the lookup for
`include=players`, `include=player`, `include=Player`, `include=Players`
lookup relationships.
2016-10-26 11:19:36 -07:00
Joey Baker
07176018b7 Fix(crud) include param can be a string or array 2016-10-26 10:59:02 -07:00
Joey Baker
83eadf0929 Fix: don't build CRUD routes until ready
Previously, we were building the crud routes before we had run through
the association logic. This meant that routes could get created without
a complete list of associations available to it. This is slightly less
performant b/c we need to run through two loops, but ensures that the
full association data is available to all routes.
2016-10-26 10:57:54 -07:00
Joey Baker
e318948fe4 2.7.0 2016-10-21 11:11:24 -07:00
Joey Baker
d35b616a13 Merge pull request #25 from mdibaiee/add-filters
Feat add support of limit, offset, order
2016-10-21 11:10:35 -07:00
Joey Baker
8966d7b287 Feat add support of limit, offset, order
Allows passing these as query params to list and scope methods.
2016-10-21 11:07:27 -07:00
Joey Baker
5923f0dbcb Test(crud) ensure list doesn't error 2016-10-20 17:20:22 -07:00
Joey Baker
adb1d71984 Chore(deps) update patches and minors 2016-10-20 17:20:22 -07:00
Joey Baker
3c516aa604 Chore gitignore mac junk files 2016-09-28 21:16:38 -07:00
Joey Baker
ddc6fcceb8 Chore (build) set sourcemaps to inline
This ensures that node can read the sourcemaps and provide useful
stacktraces
2016-09-28 21:16:17 -07:00
Joey Baker
f403e214a9 Docs show how to interact with hapi hooks
Fixes #3
2016-09-19 21:47:12 -07:00
Joey Baker
71e6390282 Docs: add "modify the response format" section 2016-09-13 19:44:36 -07:00
Joey Baker
a720e30a85 2.6.2 2016-09-08 13:27:20 -07:00
Joey Baker
518c4a4226 2.6.1 2016-09-08 13:27:10 -07:00
Joey Baker
469aaec66f Merge pull request #23 from mdibaiee/fix-joi-concat
Fix (validation) use joi's concat
2016-09-08 13:26:50 -07:00
Joey Baker
8ee5661252 Test: only run src test files #oops 2016-09-08 13:24:08 -07:00
Joey Baker
c59943a717 Fix (validation) use joi's concat
It turns out defaultsDeep doesn't ever correctly combine Joi objects.
So, the only option is to use Joi's concat method to combine Joi
schemas. This complicates `getConfigForMethod`, but simplifies actual
route creation.

I ran into this because I'm setting up [lout](https://github.com/hapijs/lout)
on a server, and it requires properly formatted Joi schemas. This leads
me to believe there was something already wrong and Lout just exposed
the problem.
2016-09-08 13:20:50 -07:00
Joey Baker
8cdfc5858d Merge pull request #24 from mdibaiee/use-json
Fix toJSON responses
2016-09-08 11:33:47 -07:00
Joey Baker
4e078f5ba5 Fix toJSON responses
This is a non-obvious one. Hapi is happy to convert raw sequelize
instances to proper JSON (likely because Sequelize does nice things),
but we do that, we can't use `config.response.schema`, because it
receives the full sequelize instance instead of JSON.

This is a patch release.
2016-09-07 21:06:34 -07:00
Joey Baker
85111c7dc8 2.6.0 2016-09-06 11:30:22 -07:00
Joey Baker
196999a4c5 Merge pull request #20 from Getable/error-on-invalid-where
Validate query and payload
2016-09-06 11:29:40 -07:00
Joey Baker
3e9f024dcf Test: now testing get-config-for-method 2016-09-06 11:25:03 -07:00
Joey Baker
4c9ae36c5c Refactor: move get-config-for-method to a file 2016-09-06 11:24:41 -07:00
Joey Baker
4558ad1327 Chore (deps) update minors and patches
Not strictly necessary, but kinda nice to prove we're up-to-date
2016-09-06 09:25:02 -07:00
Joey Baker
edccfb2316 Chore (deps) update Joi 7 → 9
Shouldn't impact us
2016-09-06 07:28:43 -07:00
Joey Baker
0d8ab9f02e Chore (deps) update boom (major)
They just removed a method we don't use.
2016-09-06 07:28:43 -07:00
Joey Baker
f062e2b37f Fix (validation) params is a plain object
If we use a Joi object here, we can't use `defaultsDeep` to extend b/c
the joi prototype won't extend cleanly. We'd need to use joi's `contact`
method, but that gets really complicated and error prone. So, just use
a plain object which is more correct anyway.

http://hapijs.com/tutorials/validation
2016-09-06 07:28:43 -07:00
Joey Baker
69221ea331 Feat query & payload now validated 2016-09-06 07:28:43 -07:00
Joey Baker
f33c8da55d Fix (CRUD update) validate id 2016-09-06 07:28:43 -07:00
Joey Baker
833df49173 Chore add comments for config creation 2016-09-06 07:28:43 -07:00
Joey Baker
32a539c3d9 Fix (crud) update: findOnefindById
b/c `findById` uses an index to lookup, and should be fast.
2016-09-06 07:28:43 -07:00
Joey Baker
b35bd23c91 Fix: prefer user's config before our own 2016-09-06 07:28:43 -07:00
Joey Baker
b4ea8c5b8e Docs: add more details for include and where 2016-09-06 07:28:43 -07:00
Joey Baker
85cd2823da Docs: #cleanup and style fixes 2016-09-06 07:28:43 -07:00
Joey Baker
e0132c2cae Fix: handle all parseInclude errors 2016-09-06 07:28:43 -07:00
Mahdi Dibaiee
bd18c57529 chore: put CircleCI badge in the same line as heading 2016-09-06 09:30:21 +04:30
Mahdi Dibaiee
3e53ba8d2c Merge branch 'master' of github.com:mdibaiee/hapi-sequelize-crud 2016-09-06 09:29:39 +04:30
Mahdi Dibaiee
81b704a395 chore: add CircleCI badge 2016-09-06 09:28:56 +04:30
Mahdi Dibaiee
d69b87b8fa Merge pull request #22 from mdibaiee/tests
Add Tests
2016-09-06 09:25:24 +04:30
Joey Baker
03755f94c5 Test (CI) configure circle 2016-09-05 17:37:50 -07:00
Joey Baker
7cecd7fb40 Test (list) add initial list tests 2016-09-05 17:12:13 -07:00
Joey Baker
7b757fcc50 Fix (crud) if no prefix, things still work 2016-09-05 17:11:42 -07:00
Joey Baker
de0685c8bb Chore: install and configure AVA 2016-09-05 17:10:58 -07:00
Joey Baker
f2f613b35b Fix: boom error on invalid include
Sends a 501 `notImplemented` error when `parseInclude` can't find models
to include.
2016-09-05 17:08:09 -07:00
Joey Baker
38ccb3adf6 Chore (deps) update eslint (major)
Breaking changes shouldn't affect us
2016-09-05 15:43:48 -07:00
Joey Baker
506d42f39a 2.5.4 2016-09-05 12:30:15 -07:00
Joey Baker
3dfa72ddee Merge pull request #21 from Getable/better-error
Fix a bug in error parsing
2016-09-05 12:25:14 -07:00
Joey Baker
bab2e90cbb Feat (error) parse PG 42* errors 2016-09-04 17:28:16 -07:00
Joey Baker
da6b3ce963 Feat (error) include hint on PG errors
Provides useful info!
2016-09-04 17:28:09 -07:00
Joey Baker
b032be20d1 Fix (error) always reply with an error #oops
We had a case where reply would never be called. This could case a
server hang.
2016-09-04 17:28:01 -07:00
Mahdi Dibaiee
79c6a81a3a chore(version): bump version 2016-08-30 09:49:07 +04:30
Mahdi Dibaiee
517f2b8157 Merge pull request #17 from Getable/fix-babel-polyfill
Fixes for babel-polyfill
2016-08-30 09:48:20 +04:30
Joey Baker
a9fa790ae9 Fix (deps): install babel-polyfill as optionalDep
We only require it if something else hasn't so this is a optionalDep.
2016-08-29 17:08:44 -07:00
Joey Baker
ce6f1fedde Fix: bad merge in error.js 2016-08-28 09:57:23 -07:00
Joey Baker
db86507ef9 Fix: correct babel-polyfill version
This installs the babel-polyfill for babel 6. #oops
2016-08-28 09:56:46 -07:00
Mahdi Dibaiee
6ad9df2db1 chore: bump version 2016-08-26 13:15:50 +04:30
Mahdi Dibaiee
17105f66f4 feat(errors): parse sequelize errors and use the relevant Boom error type 2016-08-26 13:15:15 +04:30
Mahdi Dibaiee
b18479e02e Merge pull request #16 from Getable/parse-sequelize-errors
Parse Sequelize errors
2016-08-26 13:11:29 +04:30
Joey Baker
0e9cd935b9 Add: parse Sequelize errors
This adds intelligence around sequelize errors to parse out the correct
error status code and ensure it's always formatted as a Boom error.
2016-08-25 21:28:23 -07:00
Mahdi Dibaiee
9524e55690 chore(version): bump version 2016-08-04 10:38:18 +04:30
Mahdi Dibaiee
1752d700f5 Merge pull request #15 from Getable/better-errors
Add: Better errors
2016-08-04 10:37:29 +04:30
Joey Baker
6d289d6d78 Add: Better errors
Now looks at the error that comes back from sequelize and uses boom to
format the error in a more friendly way. This should yield useful error
messages instead of generic 500s.
2016-08-03 14:42:20 -07:00
Mahdi Dibaiee
0d6a715511 chore(CONTRIBUTING): a contribution guide, git commit messages and code linting
@joeybaker ^
2016-07-22 23:29:54 +04:30
Mahdi Dibaiee
e5d72fd034 chore(version): bump version to 2.5.0, we have a new feature 2016-07-22 23:25:55 +04:30
Mahdi Dibaiee
a0aeaef3a9 Merge pull request #14 from Getable/fix-permissions
Change: permissions must always be an array
2016-07-22 23:25:26 +04:30
Joey Baker
79b9fc1242 Change: permissions must always be an array
This allows us to set different configs per model. I should have thought
of this usecase when I first did permissions.
2016-07-22 11:50:08 -07:00
Mahdi Dibaiee
fb8275abca Merge pull request #12 from Getable/rm-extraneous-babel
Build: rm extraneous babel dep
2016-07-22 21:59:26 +04:30
Mahdi Dibaiee
098aabfea5 Merge pull request #11 from Getable/add-permissions
Add: permissions
2016-07-22 21:58:36 +04:30
Joey Baker
f95f411a65 Add: permissions
It's now possible to limit the models rest routes are created for. This
is done via a `models` option that can be simple to complex. The readme
has been updated to reflect this.
2016-07-22 10:14:17 -07:00
Joey Baker
0416986896 Build: rm extraneous babel dep #oops 2016-07-22 09:52:21 -07:00
Mahdi Dibaiee
6c46ff68d0 fix(primaryKey): use model.primaryKey instead of hardcoded id for id routes, fixes #9 2016-07-13 11:27:48 +04:30
Mahdi Dibaiee
29ee49fc62 fix(name): default name parameter on options object itself 2016-07-13 10:05:33 +04:30
Mahdi Dibaiee
d142e6c553 fix(options): take a name parameter, same as the name parameter used in hapi-sequelize, fixes #8 2016-07-13 10:03:00 +04:30
Mahdi Dibaiee
fb06d9cd27 chore(version): bump version up 2016-07-13 09:59:55 +04:30
Mahdi Dibaiee
b9e150200f chore: switch from Gruntfiles to npm scripts, see #5 2016-07-13 09:59:12 +04:30
Mahdi Dibaiee
fe6881099f Merge branch 'babel6' of github.com:Getable/hapi-sequelize-crud into babel6 2016-07-13 09:50:44 +04:30
Mahdi Dibaiee
6786b9b487 style: updated eslint config 2016-07-09 09:41:23 +04:30
Mahdi Dibaiee
aa06808ab2 chore(version): bump version to 2.1.1, two small patches were applied by @joeybaker 2016-07-09 09:14:18 +04:30
Mahdi Dibaiee
2d1ab6b75a Merge pull request #6 from Getable/fix-include
Fix: include option api correction
2016-07-09 09:13:14 +04:30
Joey Baker
2ea6c2e3a8 Fix: include option api correction
AFAIK, hapi-sequelize doesn't have a `request.models`, but it does have
a `request.getDb()` method that has `models` on it. This calls that
method to get the related models, but allows `request.models` to keep
working for backward compatibility.
2016-07-08 15:28:33 -07:00
Mahdi Dibaiee
c0cb2c44e0 Merge pull request #7 from Getable/require-payload-on-put
Fix: PUT requires a payload to work
2016-07-08 17:17:40 +04:30
Joey Baker
e7bc048a46 Internal: upgrade to babel6
Upgrades to babel 6. The major win (aside from some minor
performance gains), is that this module can now be `npm link`ed into a
babel 6 code base.
2016-07-05 14:47:44 -07:00
37 changed files with 2742 additions and 213 deletions

View File

@ -1,3 +1,14 @@
{
"stage": 1
"presets": [
"stage-1"
],
"plugins": [
"transform-object-rest-spread",
"transform-class-properties",
"add-module-exports",
"closure-elimination",
"transform-decorators-legacy",
"transform-es2015-modules-commonjs"
],
"sourceMaps": "inline"
}

View File

@ -1,3 +1,9 @@
{
"extends": "pichak"
"plugins": [
"ava"
],
"extends": [
"pichak",
"plugin:ava/recommended"
]
}

6
.gitignore vendored
View File

@ -33,3 +33,9 @@ node_modules
# Debug log from npm
npm-debug.log
# System
.DS_Store
coverage.lcov
.nyc_output

7
CONTRIBUTING Normal file
View File

@ -0,0 +1,7 @@
Commit Message
===============
Please follow [this convention](http://karma-runner.github.io/1.0/dev/git-commit-msg.html) for git commit message.
Lint
====
Please lint your code using `npm run lint` (also `npm run lint -- --fix` to auto-fix).

View File

@ -1,29 +0,0 @@
module.exports = function(grunt) {
grunt.initConfig({
babel: {
scripts: {
files: [{
expand: true,
cwd: 'src',
src: '**/*.js',
dest: 'build/'
}]
}
},
clean: {
files: ['build/**/*.js']
},
watch: {
scripts: {
files: ['src/**/*.js', 'server/**/*.js'],
tasks: ['babel']
}
}
});
grunt.loadNpmTasks('grunt-babel');
grunt.loadNpmTasks('grunt-contrib-clean');
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.registerTask('default', ['clean', 'babel']);
};

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2021 The hapi-sequelize-crud Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

192
README.md
View File

@ -1,4 +1,4 @@
hapi-sequelize-crud
hapi-sequelize-crud [![CircleCI](https://circleci.com/gh/mdibaiee/hapi-sequelize-crud.svg?style=svg)](https://circleci.com/gh/mdibaiee/hapi-sequelize-crud)
===================
Automatically generate a RESTful API for your models and associations
@ -9,7 +9,10 @@ This plugin depends on [`hapi-sequelize`](https://github.com/danecando/hapi-sequ
npm install -S hapi-sequelize-crud
```
##Configure
## Configure
Please note that you should register `hapi-sequelize-crud` after defining your
associations.
```javascript
// First, register hapi-sequelize
@ -28,15 +31,189 @@ await register({
register: require('hapi-sequelize-crud'),
options: {
prefix: '/v1',
defaultConfig: { ... } // passed as `config` to all routes created
name: 'db', // the same name you used for configuring `hapi-sequelize` (options.name)
defaultConfig: { ... }, // passed as `config` to all routes created
// You can specify which models must have routes defined for using the
// `models` property. If you omit this property, all models will have
// models defined for them. e.g.
models: ['cat', 'dog'] // only the cat and dog models will have routes created
// or
models: [
// possible methods: list, get, scope, create, destroy, destroyAll, destroyScope, update
// the cat model only has get and list methods enabled
{model: 'cat', methods: ['get', 'list']},
// the dog model has all methods enabled
{model: 'dog'},
// the cow model also has all methods enabled
'cow',
// the bat model as a custom config for the list method, but uses the default config for create.
// `config` if provided, overrides the default config
{model: 'bat', methods: ['list'], config: { ... }},
{model: 'bat', methods: ['create']}
{model: 'fly', config: {
// interact with the request before hapi-sequelize-crud does
, ext: {
onPreHandler: (request, reply) => {
if (request.auth.hasAccessToFly) reply.continue()
else reply(Boom.unauthorized())
}
}
// change the response data
response: {
schema: {id: joi.string()},
modify: true
}
}}
]
}
});
```
Please note that you should register `hapi-sequelize-crud` after defining your
associations.
### Methods
* **list**: get all rows in a table
* **get**: get a single row
* **scope**: reference a [sequelize scope](http://docs.sequelizejs.com/en/latest/api/model/#scopeoptions-model)
* **create**: create a new row
* **destroy**: delete a row
* **destroyAll**: delete all models in the table
* **destroyScope**: use a [sequelize scope](http://docs.sequelizejs.com/en/latest/api/model/#scopeoptions-model) to find rows, then delete them
* **update**: update a row
##What do I get
## `where` queries
It's easy to restrict your requests using Sequelize's `where` query option. Just pass a query parameter.
```js
// returns only teams that have a `city` property of "windsor"
// GET /team?city=windsor
// results in the Sequelize query:
Team.findOne({ where: { city: 'windsor' }})
```
You can also do more complex queries by setting the value of a key to JSON.
```js
// returns only teams that have a `address.city` property of "windsor"
// GET /team?city={"address": "windsor"}
// or
// GET /team?city[address]=windsor
// results in the Sequelize query:
Team.findOne({ where: { address: { city: 'windsor' }}})
```
## `include` queries
Getting related models is easy, just use a query parameter `include`.
```js
// returns all teams with their related City model
// GET /teams?include=city
// results in a Sequelize query:
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
// 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>`.
```js
// returns 10 teams starting from the 10th
// GET /teams?limit=10&offset=10
// results in a Sequelize query:
Team.findAll({limit: 10, offset: 10})
```
## `order` queries
You can change the order of the resulting query by passing `order` to the query.
```js
// returns the teams ordered by the name column
// GET /teams?order[]=name
// results in a Sequelize query:
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']]})
```
```js
// returns the teams ordered by the name, then the city columns, descending
// GET /teams?order[0]=name&order[1]=city
// results in a Sequelize query:
Team.findAll({order: [['name'], ['city']]})
```
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.
```js
// returns the players ordered by the team name
// GET /players?order[0]={"model": "Team"}&order[0]=name
// results in a Sequelize query:
Player.findAll({order: [[{model: Team}, 'name']]})
// if the above returns a Sequelize error: `No such column Team.name`,
// you can work around this by forcing the join into the query:
// GET /players?order[0]={"model": "Team"}&order[0]=name&include=team
// results in a Sequelize query:
Player.findAll({order: [[{model: Team}, 'name']], include: [Team]})
```
## Authorization and other hooks
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.
## Modify the response format
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).
```js
await register({
register: require('hapi-sequelize-crud'),
options: {
{model: 'fly', config: {
response: {
// setting this schema will restrict the response to only the id
schema: { id: joi.string() },
// This tells Hapi to restrict the response to the keys specified in `schema`
modify: true
}
}}
}
})
```
## Full list of methods
Let's say you have a `many-to-many` association like this:
@ -51,8 +228,9 @@ You get these:
# get an array of records
GET /team/{id}/roles
GET /role/{id}/teams
# might also append query parameters to search for
# might also append `where` query parameters to search for
GET /role/{id}/teams?members=5
GET /role/{id}/teams?city=healdsburg
# you might also use scopes
GET /teams/{scope}/roles/{scope}

13
circle.yml Normal file
View File

@ -0,0 +1,13 @@
machine:
node:
version: 6.9.0
dependencies:
pre:
- npm prune
post:
- mkdir -p $CIRCLE_TEST_REPORTS/ava
test:
post:
- npm run coverage

View File

@ -1,16 +1,20 @@
{
"name": "hapi-sequelize-crud",
"version": "2.1.0",
"version": "2.9.3",
"description": "Hapi plugin that automatically generates RESTful API for CRUD",
"main": "build/index.js",
"config": {
"ghooks": {
"pre-commit": "npm run lint && grunt"
"pre-commit": "npm run lint && npm run build"
}
},
"scripts": {
"lint": "eslint src test",
"test": "echo \"Error: no test specified\" && exit 1"
"lint": "eslint src",
"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"
@ -21,19 +25,53 @@
"author": "Mahdi Dibaiee <mdibaiee@aol.com> (http://dibaiee.ir/)",
"license": "MIT",
"devDependencies": {
"babel": "5.8.3",
"eslint": "2.10.2",
"eslint-config-pichak": "1.0.1",
"ghooks": "1.0.3",
"grunt": "0.4.5",
"grunt-babel": "5.0.3",
"grunt-contrib-clean": "0.7.0",
"grunt-contrib-watch": "0.6.1"
"ava": "^0.16.0",
"babel-cli": "^6.16.0",
"babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-closure-elimination": "^1.0.6",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"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": {
"babel": "5.8.3",
"boom": "^3.2.2",
"joi": "7.2.1",
"lodash": "4.0.0"
"boom": "^4.2.0",
"joi": "^9.2.0",
"lodash": "^4.16.4"
},
"optionalDependencies": {
"babel-polyfill": "^6.13.0"
},
"nyc": {
"cache": true
},
"ava": {
"source": [
"src/**/*.js",
"!build/**/*"
],
"files": [
"**/*.test.js",
"!build/**/*"
],
"require": [
"babel-register"
]
}
}

15
scripts/build.sh Executable file
View File

@ -0,0 +1,15 @@
#!/bin/bash
# strict mode http://redsymbol.net/articles/unofficial-bash-strict-mode/
set -euo pipefail
IFS=$'\n\t'
source "scripts/env.sh"
babel="./node_modules/.bin/babel"
build () {
$babel "$SRC_DIR" --out-dir "$OUT_DIR" $@
}
build $@

7
scripts/env.sh Normal file
View File

@ -0,0 +1,7 @@
#!/bin/bash
# strict mode http://redsymbol.net/articles/unofficial-bash-strict-mode/
set -euo pipefail
IFS=$'\n\t'
export SRC_DIR="./src"
export OUT_DIR="./build"

14
scripts/test.sh Executable file
View 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

6
scripts/watch.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
# strict mode http://redsymbol.net/articles/unofficial-bash-strict-mode/
set -euo pipefail
IFS=$'\n\t'
./scripts/build.sh --watch

View File

@ -14,15 +14,15 @@ export default (server, a, b, names, options) => {
@error
async handler(request, reply) {
let instanceb = await b.findOne({
const instanceb = await b.findOne({
where: {
id: request.params.bid,
[b.primaryKeyField]: request.params.bid,
},
});
let instancea = await a.findOne({
const instancea = await a.findOne({
where: {
id: request.params.aid,
[a.primaryKeyField]: request.params.aid,
},
});

View File

@ -28,18 +28,23 @@ export const get = (server, a, b, names) => {
async handler(request, reply) {
const include = parseInclude(request);
const base = a.findOne({
const base = await a.findOne({
where: {
id: request.params.aid,
[a.primaryKeyField]: request.params.aid,
},
});
const method = getMethod(base, names.b);
const list = await method({ where: {
id: request.params.bid,
[b.primaryKeyField]: request.params.bid,
}, include });
reply(list);
if (Array.isArray(list)) {
reply(list[0]);
} else {
reply(list);
}
},
config: defaultConfig,
@ -58,7 +63,7 @@ export const list = (server, a, b, names) => {
const base = await a.findOne({
where: {
id: request.params.aid,
[a.primaryKeyField]: request.params.aid,
},
});
@ -73,7 +78,7 @@ export const list = (server, a, b, names) => {
};
export const scope = (server, a, b, names) => {
let scopes = Object.keys(b.options.scopes);
const scopes = Object.keys(b.options.scopes);
server.route({
method: 'GET',
@ -86,7 +91,7 @@ export const scope = (server, a, b, names) => {
const base = await a.findOne({
where: {
id: request.params.aid,
[a.primaryKeyField]: request.params.aid,
},
});
@ -112,7 +117,7 @@ export const scope = (server, a, b, names) => {
};
export const scopeScope = (server, a, b, names) => {
let scopes = {
const scopes = {
a: Object.keys(a.options.scopes),
b: Object.keys(b.options.scopes),
};
@ -126,7 +131,7 @@ export const scopeScope = (server, a, b, names) => {
const include = parseInclude(request);
const where = parseWhere(request);
let list = await b.scope(request.params.scopeb).findAll({
const list = await b.scope(request.params.scopeb).findAll({
where,
include: include.concat({
model: a.scope(request.params.scopea),
@ -159,7 +164,7 @@ export const destroy = (server, a, b, names) => {
const base = await a.findOne({
where: {
id: request.params.aid,
[a.primaryKeyField]: request.params.aid,
},
});
@ -175,7 +180,7 @@ export const destroy = (server, a, b, names) => {
};
export const destroyScope = (server, a, b, names) => {
let scopes = Object.keys(b.options.scopes);
const scopes = Object.keys(b.options.scopes);
server.route({
method: 'DELETE',
@ -188,7 +193,7 @@ export const destroyScope = (server, a, b, names) => {
const base = await a.findOne({
where: {
id: request.params.aid,
[a.primarykeyField]: request.params.aid,
},
});
@ -228,7 +233,7 @@ export const update = (server, a, b, names) => {
const base = await a.findOne({
where: {
id: request.params.aid,
[a.primaryKeyField]: request.params.aid,
},
});

View File

@ -26,7 +26,7 @@ export const get = (server, a, b, names) => {
const base = await a.findOne({
where: {
id: request.params.aid,
[a.primaryKeyField]: request.params.aid,
},
});
const method = getMethod(base, names.b, false);
@ -53,7 +53,7 @@ export const create = (server, a, b, names) => {
async handler(request, reply) {
const base = await a.findOne({
where: {
id: request.params.id,
[a.primaryKeyField]: request.params.id,
},
});
@ -79,10 +79,12 @@ export const destroy = (server, a, b, names) => {
const base = await a.findOne({
where: {
id: request.params.aid,
[a.primaryKeyField]: request.params.aid,
},
});
where[b.primaryKeyField] = request.params.bid;
const method = getMethod(base, names.b, false, 'get');
const instance = await method({ where, include });
await instance.destroy();
@ -110,6 +112,8 @@ export const update = (server, a, b, names) => {
},
});
where[b.primaryKeyField] = request.params.bid;
const method = getMethod(base, names.b, false);
const instance = await method({ where, include });

View 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);
});

View 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));
});

View 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);
});

View File

@ -0,0 +1,94 @@
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('/players?limit=2', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players?limit=${limit}`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, limit);
});
test('/players?limit=2&offset=1', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players?limit=${limit}&offset=1`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, limit);
});
test('/players?limit=2&offset=2', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players?limit=${limit}&offset=2`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, 1, 'with only 3 players, only get 1 back with an offset of 2');
});
test('/players?limit=2&offset=20', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players?limit=${limit}&offset=20`;
const method = 'GET';
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_NOT_FOUND, 'with a offset/limit greater than the data, returns a 404');
});
test('scope /players/returnsAll?limit=2', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players/returnsAll?limit=${limit}`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, limit);
});
test('scope /players/returnsAll?limit=2&offset=1', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players/returnsAll?limit=${limit}&offset=1`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, limit);
});
test('scope /players/returnsAll?limit=2&offset=2', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players/returnsAll?limit=${limit}&offset=2`;
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.length, 1, 'with only 3 players, only get 1 back with an offset of 2');
});
test('scope /players/returnsAll?limit=2&offset=20', async (t) => {
const { server } = t.context;
const limit = 2;
const url = `/players/returnsAll?limit=${limit}&offset=20`;
const method = 'GET';
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_NOT_FOUND, 'with a offset/limit greater than the data, returns a 404');
});

View File

@ -0,0 +1,141 @@
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, player3 } = 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);
t.is(result[2].name, player3.name);
});
test('/players?order=name%20ASC', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = 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);
t.is(result[2].name, player3.name);
});
test('/players?order=name%20DESC', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = 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, player3.name);
t.is(result[1].name, player2.name);
t.is(result[2].name, player1.name);
});
test('/players?order[]=name', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = 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);
t.is(result[2].name, player3.name);
});
test('/players?order[0]=name&order[0]=DESC', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = 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, player3.name);
t.is(result[1].name, player2.name);
t.is(result[2].name, player1.name);
});
// multiple sorts
test('/players?order[0]=active&order[0]=DESC&order[1]=name&order[1]=DESC', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order[0]=name&order[0]=DESC&order[1]=teamId&order[1]=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, player3.name);
t.is(result[1].name, player2.name);
t.is(result[2].name, player1.name);
});
// this will fail b/c sequelize doesn't correctly do the join when you pass
// an order. There are many issues for this:
// eslint-disable-next-line
// https://github.com/sequelize/sequelize/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20order%20join%20
//
// https://github.com/sequelize/sequelize/issues/5353 is a good example
// if this test passes, that's great! Just remove the workaround note in the
// docs
// eslint-disable-next-line
test.failing('sequelize bug /players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order[0]={"model":"Team"}&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, player3.name);
t.is(result[1].name, player1.name);
t.is(result[2].name, player2.name);
});
// b/c the above fails, this is a work-around
test('/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC&include=team', async (t) => {
const { server, instances } = t.context;
const { player1, player2, player3 } = instances;
const url = '/players?order[0]={"model":"Team"}&order[0]=name&order[0]=DESC&include=team';
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, player3.name);
t.is(result[1].name, player1.name);
t.is(result[2].name, player2.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'));
});

View 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' });
});

View 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);
});

View 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);
});

View 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));
});

View File

@ -1,214 +1,350 @@
import joi from 'joi';
import path from 'path';
import error from './error';
import _ from 'lodash';
import { parseInclude, parseWhere } from './utils';
import { parseInclude, parseWhere, parseLimitAndOffset, parseOrder } from './utils';
import { notFound } from 'boom';
import * as associations from './associations/index';
import getConfigForMethod from './get-config-for-method.js';
let prefix;
let defaultConfig;
export default (server, model, options) => {
prefix = options.prefix;
defaultConfig = options.defaultConfig;
list(server, model);
get(server, model);
scope(server, model);
create(server, model);
destroy(server, model);
destroyAll(server, model);
destroyScope(server, model);
update(server, model);
};
export const list = (server, model) => {
server.route({
method: 'GET',
path: `${prefix}/${model._plural}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const where = parseWhere(request);
const list = await model.findAll({
where, include,
});
reply(list);
},
config: defaultConfig,
const createAll = ({
server,
model,
prefix,
config,
attributeValidation,
associationValidation,
scopes,
}) => {
Object.keys(methods).forEach((method) => {
methods[method]({
server,
model,
prefix,
config: getConfigForMethod({
method,
attributeValidation,
associationValidation,
config,
scopes,
}),
});
});
};
export const get = (server, model) => {
export { associations };
/*
The `models` option, becomes `permissions`, and can look like:
```
models: ['cat', 'dog']
```
or
```
models: {
cat: ['list', 'get']
, dog: true // all
}
```
*/
export default (server, model, { prefix, defaultConfig: config, models: permissions }) => {
const modelName = model._singular;
const modelAttributes = Object.keys(model.attributes);
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(validAssociations), validAssociations],
};
const scopes = Object.keys(model.options.scopes);
// if we don't have any permissions set, just create all the methods
if (!permissions) {
createAll({
server,
model,
prefix,
config,
attributeValidation,
associationValidation,
scopes,
});
// if permissions are set, but we can't parse them, throw an error
} else if (!Array.isArray(permissions)) {
throw new Error('hapi-sequelize-crud: `models` property must be an array');
// if permissions are set, but the only thing we've got is a model name, there
// are no permissions to be set, so just create all methods and move on
} else if (permissions.includes(modelName)) {
createAll({
server,
model,
prefix,
config,
attributeValidation,
associationValidation,
scopes,
});
// if we've gotten here, we have complex permissions and need to set them
} else {
const permissionOptions = permissions.filter((permission) => {
return permission.model === modelName;
});
permissionOptions.forEach((permissionOption) => {
if (_.isPlainObject(permissionOption)) {
const permissionConfig = permissionOption.config || config;
if (permissionOption.methods) {
permissionOption.methods.forEach((method) => {
methods[method]({
server,
model,
prefix,
config: getConfigForMethod({
method,
attributeValidation,
associationValidation,
scopes,
config: permissionConfig,
}),
});
});
} else {
createAll({
server,
model,
prefix,
attributeValidation,
associationValidation,
scopes,
config: permissionConfig,
});
}
}
});
}
};
export const list = ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: `${prefix}/${model._singular}/{id?}`,
path: path.join(prefix, model._plural),
@error
async handler(request, reply) {
const include = parseInclude(request);
const where = parseWhere(request);
const {id} = request.params;
if (id) where.id = id;
const { limit, offset } = parseLimitAndOffset(request);
const order = parseOrder(request);
if (include instanceof Error) return void reply(include);
const list = await model.findAll({
where, include, limit, offset, order,
});
if (!list.length) return void reply(notFound('Nothing found.'));
reply(list.map((item) => item.toJSON()));
},
config,
});
};
export const get = ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: path.join(prefix, model._singular, '{id?}'),
@error
async handler(request, reply) {
const include = parseInclude(request);
const where = parseWhere(request);
const { id } = request.params;
if (id) where[model.primaryKeyField] = id;
if (include instanceof Error) return void reply(include);
const instance = await model.findOne({ where, include });
if (!instance) return void reply(notFound(`${id} not found.`));
reply(instance);
reply(instance.toJSON());
},
config: _.defaultsDeep({
validate: {
params: joi.object().keys({
id: joi.any(),
}),
},
}, defaultConfig),
config,
});
};
export const scope = (server, model) => {
let scopes = Object.keys(model.options.scopes);
export const scope = ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: `${prefix}/${model._plural}/{scope}`,
path: path.join(prefix, model._plural, '{scope}'),
@error
async handler(request, reply) {
const include = parseInclude(request);
const where = parseWhere(request);
const { limit, offset } = parseLimitAndOffset(request);
const order = parseOrder(request);
const list = await model.scope(request.params.scope).findAll({ include, where });
if (include instanceof Error) return void reply(include);
reply(list);
const list = await model.scope(request.params.scope).findAll({
include, where, limit, offset, order,
});
if (!list.length) return void reply(notFound('Nothing found.'));
reply(list.map((item) => item.toJSON()));
},
config: _.defaultsDeep({
validate: {
params: joi.object().keys({
scope: joi.string().valid(...scopes),
}),
},
}, defaultConfig),
config,
});
};
export const create = (server, model) => {
export const create = ({ server, model, prefix = '/', config }) => {
server.route({
method: 'POST',
path: `${prefix}/${model._singular}`,
path: path.join(prefix, model._singular),
@error
async handler(request, reply) {
const instance = await model.create(request.payload);
reply(instance);
reply(instance.toJSON());
},
config: defaultConfig,
config,
});
};
export const destroy = (server, model) => {
export const destroy = ({ server, model, prefix = '/', config }) => {
server.route({
method: 'DELETE',
path: `${prefix}/${model._singular}/{id?}`,
path: path.join(prefix, model._singular, '{id?}'),
@error
async handler(request, reply) {
const where = parseWhere(request);
if (request.params.id) where.id = 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()));
reply(list.length === 1 ? list[0] : list);
const listAsJSON = list.map((item) => item.toJSON());
reply(listAsJSON.length === 1 ? listAsJSON[0] : listAsJSON);
},
config: defaultConfig,
config,
});
};
export const destroyAll = (server, model) => {
export const destroyAll = ({ server, model, prefix = '/', config }) => {
server.route({
method: 'DELETE',
path: `${prefix}/${model._plural}`,
path: path.join(prefix, model._plural),
@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()));
reply(list.length === 1 ? list[0] : list);
const listAsJSON = list.map((item) => item.toJSON());
reply(listAsJSON.length === 1 ? listAsJSON[0] : listAsJSON);
},
config: defaultConfig,
config,
});
};
export const destroyScope = (server, model) => {
let scopes = Object.keys(model.options.scopes);
export const destroyScope = ({ server, model, prefix = '/', config }) => {
server.route({
method: 'DELETE',
path: `${prefix}/${model._plural}/{scope}`,
path: path.join(prefix, model._plural, '{scope}'),
@error
async handler(request, reply) {
const include = parseInclude(request);
const where = parseWhere(request);
let list = await model.scope(request.params.scope).findAll({ include, where });
if (include instanceof Error) return void reply(include);
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()));
reply(list);
const listAsJSON = list.map((item) => item.toJSON());
reply(listAsJSON.length === 1 ? listAsJSON[0] : listAsJSON);
},
config: _.defaultsDeep({
validate: {
params: joi.object().keys({
scope: joi.string().valid(...scopes),
}),
},
}, defaultConfig),
config,
});
};
export const update = (server, model) => {
export const update = ({ server, model, prefix = '/', config }) => {
server.route({
method: 'PUT',
path: `${prefix}/${model._singular}/{id}`,
path: path.join(prefix, model._singular, '{id}'),
@error
async handler(request, reply) {
const {id} = request.params;
const instance = await model.findOne({
where: {
id,
},
});
const { id } = request.params;
const instance = await model.findById(id);
if (!instance) return void reply(notFound(`${id} not found.`));
await instance.update(request.payload);
reply(instance);
reply(instance.toJSON());
},
config: _.defaultsDeep({
validate: {
payload: joi.object().required(),
},
}, defaultConfig),
config,
});
};
import * as associations from './associations/index';
export { associations };
const methods = {
list, get, scope, create, destroy, destroyAll, destroyScope, update,
};

231
src/crud.test.js Normal file
View File

@ -0,0 +1,231 @@
import test from 'ava';
import { list } from './crud.js';
import { stub } from 'sinon';
import uniqueId from 'lodash/uniqueId.js';
import 'sinon-bluebird';
const METHODS = {
GET: 'GET',
};
test.beforeEach('setup server', (t) => {
t.context.server = {
route: stub(),
};
});
const makeModel = () => {
const id = uniqueId();
return {
findAll: stub(),
_plural: 'models',
_singular: 'model',
toJSON: () => ({ id }),
id,
};
};
test.beforeEach('setup model', (t) => {
t.context.model = makeModel();
});
test.beforeEach('setup models', (t) => {
t.context.models = [t.context.model, makeModel()];
});
test.beforeEach('setup request stub', (t) => {
t.context.request = {
query: {},
payload: {},
models: t.context.models,
};
});
test.beforeEach('setup reply stub', (t) => {
t.context.reply = stub();
});
test('crud#list without prefix', (t) => {
const { server, model } = t.context;
list({ server, model });
const { path } = server.route.args[0][0];
t.falsy(
path.includes('undefined'),
'correctly sets the path without a prefix defined',
);
t.is(
path,
`/${model._plural}`,
'the path sets to the plural model'
);
});
test('crud#list with prefix', (t) => {
const { server, model } = t.context;
const prefix = '/v1';
list({ server, model, prefix });
const { path } = server.route.args[0][0];
t.is(
path,
`${prefix}/${model._plural}`,
'the path sets to the plural model with the prefix'
);
});
test('crud#list method', (t) => {
const { server, model } = t.context;
list({ server, model });
const { method } = server.route.args[0][0];
t.is(
method,
METHODS.GET,
`sets the method to ${METHODS.GET}`
);
});
test('crud#list config', (t) => {
const { server, model } = t.context;
const userConfig = {};
list({ server, model, config: userConfig });
const { config } = server.route.args[0][0];
t.is(
config,
userConfig,
'sets the user config'
);
});
test('crud#list handler', async (t) => {
const { server, model, request, reply, models } = t.context;
list({ server, model });
const { handler } = server.route.args[0][0];
model.findAll.resolves(models);
try {
await handler(request, reply);
} catch (e) {
t.ifError(e, 'does not error while handling');
} finally {
t.pass('does not error while handling');
}
t.truthy(
reply.calledOnce
, 'calls reply only once'
);
const response = reply.args[0][0];
t.falsy(response instanceof Error, response);
t.deepEqual(
response,
models.map(({ id }) => ({ id })),
'responds with the list of models'
);
});
test('crud#list handler if parseInclude errors', async (t) => {
const { server, model, request, reply } = t.context;
// we _want_ the error
delete request.models;
list({ server, model });
const { handler } = server.route.args[0][0];
await handler(request, reply);
t.truthy(
reply.calledOnce
, 'calls reply only once'
);
const response = reply.args[0][0];
t.truthy(
response.isBoom,
'responds with a Boom error'
);
});
test('crud#list handler with limit', async (t) => {
const { server, model, request, reply, models } = t.context;
const { findAll } = model;
// set the limit
request.query.limit = 1;
list({ server, model });
const { handler } = server.route.args[0][0];
model.findAll.resolves(models);
try {
await handler(request, reply);
} catch (e) {
t.ifError(e, 'does not error while handling');
} finally {
t.pass('does not error while handling');
}
t.truthy(
reply.calledOnce
, 'calls reply only once'
);
const response = reply.args[0][0];
const findAllArgs = findAll.args[0][0];
t.falsy(response instanceof Error, response);
t.is(
findAllArgs.limit,
request.query.limit,
'queries with the limit'
);
});
test('crud#list handler with order', async (t) => {
const { server, model, request, reply, models } = t.context;
const { findAll } = model;
// set the limit
request.query.order = 'key';
list({ server, model });
const { handler } = server.route.args[0][0];
model.findAll.resolves(models);
try {
await handler(request, reply);
} catch (e) {
t.ifError(e, 'does not error while handling');
} finally {
t.pass('does not error while handling');
}
t.truthy(
reply.calledOnce
, 'calls reply only once'
);
const response = reply.args[0][0];
const findAllArgs = findAll.args[0][0];
t.falsy(response instanceof Error, response);
t.deepEqual(
findAllArgs.order,
[[request.query.order]],
'queries with the order as an array b/c that\'s what sequelize wants'
);
});

View File

@ -1,12 +1,65 @@
import Boom from 'boom';
export default (target, key, descriptor) => {
let fn = descriptor.value;
const fn = descriptor.value;
descriptor.value = async (request, reply) => {
try {
await fn(request, reply);
} catch(e) {
console.error(e);
reply(e);
} catch (e) {
if (e.original) {
const { code, detail, hint } = e.original;
let error;
// pg error codes https://www.postgresql.org/docs/9.5/static/errcodes-appendix.html
if (code && (code.startsWith('22') || code.startsWith('23'))) {
error = Boom.wrap(e, 406);
} else if (code && (code.startsWith('42'))) {
error = Boom.wrap(e, 422);
// TODO: we could get better at parse postgres error codes
} else {
// use a 502 error code since the issue is upstream with postgres, not
// this server
error = Boom.wrap(e, 502);
}
// detail tends to be more specific information. So, if we have it, use.
if (detail) {
error.message += `: ${detail}`;
error.reformat();
}
// hint might provide useful information about how to fix the problem
if (hint) {
error.message += ` Hint: ${hint}`;
error.reformat();
}
reply(error);
} else if (!e.isBoom) {
const { message } = e;
let err;
if (e.name === 'SequelizeValidationError')
err = Boom.badData(message);
else if (e.name === 'SequelizeConnectionTimedOutError')
err = Boom.gatewayTimeout(message);
else if (e.name === 'SequelizeHostNotReachableError')
err = Boom.serverUnavailable(message);
else if (e.name === 'SequelizeUniqueConstraintError')
err = Boom.conflict(message);
else if (e.name === 'SequelizeForeignKeyConstraintError')
err = Boom.expectationFailed(message);
else if (e.name === 'SequelizeExclusionConstraintError')
err = Boom.expectationFailed(message);
else if (e.name === 'SequelizeConnectionError')
err = Boom.badGateway(message);
else err = Boom.badImplementation(message);
reply(err);
} else {
reply(e);
}
}
};

View File

@ -0,0 +1,156 @@
import { set, get } from 'lodash';
import joi from 'joi';
// if the custom validation is a joi object we need to concat
// else, assume it's an plain object and we can just add it in with .keys
const concatToJoiObject = (joi, candidate) => {
if (!candidate) return joi;
else if (candidate.isJoi) return joi.concat(candidate);
else return joi.keys(candidate);
};
export const sequelizeOperators = {
$and: joi.any(),
$or: joi.any(),
$gt: joi.any(),
$gte: joi.any(),
$lt: joi.any(),
$lte: joi.any(),
$ne: joi.any(),
$eq: joi.any(),
$not: joi.any(),
$between: joi.any(),
$notBetween: joi.any(),
$in: joi.any(),
$notIn: joi.any(),
$like: joi.any(),
$notLike: joi.any(),
$iLike: joi.any(),
$notILike: joi.any(),
$overlap: joi.any(),
$contains: joi.any(),
$contained: joi.any(),
$any: joi.any(),
$col: joi.any(),
};
export const whereMethods = [
'list',
'get',
'scope',
'destroy',
'destoryScope',
'destroyAll',
];
export const includeMethods = [
'list',
'get',
'scope',
'destoryScope',
];
export const payloadMethods = [
'create',
'update',
];
export const scopeParamsMethods = [
'destroyScope',
'scope',
];
export const idParamsMethods = [
'get',
'update',
];
export const restrictMethods = [
'list',
'scope',
];
export default ({
method, attributeValidation, associationValidation, scopes = [], config = {},
}) => {
const hasWhere = whereMethods.includes(method);
const hasInclude = includeMethods.includes(method);
const hasPayload = payloadMethods.includes(method);
const hasScopeParams = scopeParamsMethods.includes(method);
const hasIdParams = idParamsMethods.includes(method);
const hasRestrictMethods = restrictMethods.includes(method);
// clone the config so we don't modify it on multiple passes.
let methodConfig = { ...config, validate: { ...config.validate } };
if (hasWhere) {
const query = concatToJoiObject(joi.object()
.keys({
...attributeValidation,
...sequelizeOperators,
}),
get(methodConfig, 'validate.query')
);
methodConfig = set(methodConfig, 'validate.query', query);
}
if (hasInclude) {
const query = concatToJoiObject(joi.object()
.keys({
...associationValidation,
}),
get(methodConfig, 'validate.query')
);
methodConfig = set(methodConfig, 'validate.query', query);
}
if (hasPayload) {
const payload = concatToJoiObject(joi.object()
.keys({
...attributeValidation,
}),
get(methodConfig, 'validate.payload')
);
methodConfig = set(methodConfig, 'validate.payload', payload);
}
if (hasScopeParams) {
const params = concatToJoiObject(joi.object()
.keys({
scope: joi.string().valid(...scopes),
}),
get(methodConfig, 'validate.params')
);
methodConfig = set(methodConfig, 'validate.params', params);
}
if (hasIdParams) {
const params = concatToJoiObject(joi.object()
.keys({
id: joi.any(),
}),
get(methodConfig, 'validate.params')
);
methodConfig = set(methodConfig, 'validate.params', params);
}
if (hasRestrictMethods) {
const query = concatToJoiObject(joi.object()
.keys({
limit: joi.number().min(0).integer(),
offset: joi.number().min(0).integer(),
order: [joi.array(), joi.string()],
}),
get(methodConfig, 'validate.query')
);
methodConfig = set(methodConfig, 'validate.query', query);
}
return methodConfig;
};

View File

@ -0,0 +1,580 @@
import test from 'ava';
import joi from 'joi';
import
getConfigForMethod, {
whereMethods,
includeMethods,
payloadMethods,
scopeParamsMethods,
idParamsMethods,
restrictMethods,
sequelizeOperators,
} from './get-config-for-method.js';
test.beforeEach((t) => {
t.context.models = ['MyModel'];
t.context.scopes = ['aScope'];
t.context.attributeValidation = {
myKey: joi.any(),
};
t.context.associationValidation = {
include: joi.array().items(joi.string().valid(t.context.models)),
};
t.context.config = {
cors: {},
};
});
test('validate.query seqeulizeOperators', (t) => {
whereMethods.forEach((method) => {
const configForMethod = getConfigForMethod({ method });
const { query } = configForMethod.validate;
t.truthy(
query,
`applies query validation for ${method}`
);
Object.keys(sequelizeOperators).forEach((operator) => {
t.ifError(
query.validate({ [operator]: true }).error
, `applies sequelize operator "${operator}" in validate.where for ${method}`
);
});
t.truthy(
query.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('validate.query attributeValidation', (t) => {
const { attributeValidation } = t.context;
whereMethods.forEach((method) => {
const configForMethod = getConfigForMethod({ method, attributeValidation });
const { query } = configForMethod.validate;
Object.keys(attributeValidation).forEach((key) => {
t.ifError(
query.validate({ [key]: true }).error
, `applies attributeValidation (${key}) to validate.query`
);
});
t.truthy(
query.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('query attributeValidation w/ config as plain object', (t) => {
const { attributeValidation } = t.context;
const config = {
validate: {
query: {
aKey: joi.boolean(),
},
},
};
whereMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
attributeValidation,
config,
});
const { query } = configForMethod.validate;
const keys = [
...Object.keys(attributeValidation),
...Object.keys(config.validate.query),
];
keys.forEach((key) => {
t.ifError(
query.validate({ [key]: true }).error
, `applies ${key} to validate.query`
);
});
t.truthy(
query.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('query attributeValidation w/ config as joi object', (t) => {
const { attributeValidation } = t.context;
const queryKeys = {
aKey: joi.boolean(),
};
const config = {
validate: {
query: joi.object().keys(queryKeys),
},
};
whereMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
attributeValidation,
config,
});
const { query } = configForMethod.validate;
const keys = [
...Object.keys(attributeValidation),
...Object.keys(queryKeys),
];
keys.forEach((key) => {
t.ifError(
query.validate({ [key]: true }).error
, `applies ${key} to validate.query`
);
});
t.truthy(
query.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('validate.query associationValidation', (t) => {
const { attributeValidation, associationValidation, models } = t.context;
includeMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
attributeValidation,
associationValidation,
});
const { query } = configForMethod.validate;
Object.keys(attributeValidation).forEach((key) => {
t.ifError(
query.validate({ [key]: true }).error
, `applies attributeValidation (${key}) to validate.query when include should be applied`
);
});
Object.keys(associationValidation).forEach((key) => {
t.ifError(
query.validate({ [key]: models }).error
, `applies associationValidation (${key}) to validate.query when include should be applied`
);
});
t.truthy(
query.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('query associationValidation w/ config as plain object', (t) => {
const { associationValidation, models } = t.context;
const config = {
validate: {
query: {
aKey: joi.boolean(),
},
},
};
includeMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
associationValidation,
config,
});
const { query } = configForMethod.validate;
Object.keys(associationValidation).forEach((key) => {
t.ifError(
query.validate({ [key]: models }).error
, `applies ${key} to validate.query`
);
});
Object.keys(config.validate.query).forEach((key) => {
t.ifError(
query.validate({ [key]: true }).error
, `applies ${key} to validate.query`
);
});
t.truthy(
query.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('query associationValidation w/ config as joi object', (t) => {
const { associationValidation, models } = t.context;
const queryKeys = {
aKey: joi.boolean(),
};
const config = {
validate: {
query: joi.object().keys(queryKeys),
},
};
includeMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
associationValidation,
config,
});
const { query } = configForMethod.validate;
Object.keys(associationValidation).forEach((key) => {
t.ifError(
query.validate({ [key]: models }).error
, `applies ${key} to validate.query`
);
});
Object.keys(queryKeys).forEach((key) => {
t.ifError(
query.validate({ [key]: true }).error
, `applies ${key} to validate.query`
);
});
t.truthy(
query.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('validate.payload associationValidation', (t) => {
const { attributeValidation } = t.context;
payloadMethods.forEach((method) => {
const configForMethod = getConfigForMethod({ method, attributeValidation });
const { payload } = configForMethod.validate;
Object.keys(attributeValidation).forEach((key) => {
t.ifError(
payload.validate({ [key]: true }).error
, `applies attributeValidation (${key}) to validate.payload`
);
});
t.truthy(
payload.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('payload attributeValidation w/ config as plain object', (t) => {
const { attributeValidation } = t.context;
const config = {
validate: {
payload: {
aKey: joi.boolean(),
},
},
};
payloadMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
attributeValidation,
config,
});
const { payload } = configForMethod.validate;
const keys = [
...Object.keys(attributeValidation),
...Object.keys(config.validate.payload),
];
keys.forEach((key) => {
t.ifError(
payload.validate({ [key]: true }).error
, `applies ${key} to validate.payload`
);
});
t.truthy(
payload.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('payload attributeValidation w/ config as joi object', (t) => {
const { attributeValidation } = t.context;
const payloadKeys = {
aKey: joi.boolean(),
};
const config = {
validate: {
payload: joi.object().keys(payloadKeys),
},
};
payloadMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
attributeValidation,
config,
});
const { payload } = configForMethod.validate;
const keys = [
...Object.keys(attributeValidation),
...Object.keys(payloadKeys),
];
keys.forEach((key) => {
t.ifError(
payload.validate({ [key]: true }).error
, `applies ${key} to validate.payload`
);
});
t.truthy(
payload.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('validate.params scopeParamsMethods', (t) => {
const { scopes } = t.context;
scopeParamsMethods.forEach((method) => {
const configForMethod = getConfigForMethod({ method, scopes });
const { params } = configForMethod.validate;
scopes.forEach((key) => {
t.ifError(
params.validate({ scope: key }).error
, `applies "scope: ${key}" to validate.params`
);
});
t.truthy(
params.validate({ scope: 'notAthing' }).error
, 'errors on a non-valid key'
);
});
});
test('params scopeParamsMethods w/ config as plain object', (t) => {
const { scopes } = t.context;
const config = {
validate: {
params: {
aKey: joi.boolean(),
},
},
};
scopeParamsMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
scopes,
config,
});
const { params } = configForMethod.validate;
scopes.forEach((key) => {
t.ifError(
params.validate({ scope: key }).error
, `applies "scope: ${key}" to validate.params`
);
});
Object.keys(config.validate.params).forEach((key) => {
t.ifError(
params.validate({ [key]: true }).error
, `applies ${key} to validate.params`
);
});
t.truthy(
params.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('params scopeParamsMethods w/ config as joi object', (t) => {
const { scopes } = t.context;
const paramsKeys = {
aKey: joi.boolean(),
};
const config = {
validate: {
params: joi.object().keys(paramsKeys),
},
};
scopeParamsMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
scopes,
config,
});
const { params } = configForMethod.validate;
scopes.forEach((key) => {
t.ifError(
params.validate({ scope: key }).error
, `applies "scope: ${key}" to validate.params`
);
});
Object.keys(paramsKeys).forEach((key) => {
t.ifError(
params.validate({ [key]: true }).error
, `applies ${key} to validate.params`
);
});
t.truthy(
params.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('validate.payload idParamsMethods', (t) => {
idParamsMethods.forEach((method) => {
const configForMethod = getConfigForMethod({ method });
const { params } = configForMethod.validate;
t.ifError(
params.validate({ id: 'aThing' }).error
, 'applies id to validate.params'
);
});
});
test('validate.query restrictMethods', (t) => {
restrictMethods.forEach((method) => {
const configForMethod = getConfigForMethod({ method });
const { query } = configForMethod.validate;
const restrictKeys = ['limit', 'offset'];
restrictKeys.forEach((key) => {
t.ifError(
query.validate({ [key]: 0 }).error
, `applies restriction (${key}) to validate.query`
);
});
t.ifError(
query.validate({ order: ['thing', 'DESC'] }).error
, 'applies `order` to validate.query'
);
t.truthy(
query.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('validate.query restrictMethods w/ config as plain object', (t) => {
const config = {
validate: {
query: {
aKey: joi.boolean(),
},
},
};
restrictMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
config,
});
const { query } = configForMethod.validate;
const keys = [
...Object.keys(config.validate.query),
];
keys.forEach((key) => {
t.ifError(
query.validate({ [key]: true }).error
, `applies ${key} to validate.query`
);
});
t.truthy(
query.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('validate.query restrictMethods w/ config as joi object', (t) => {
const queryKeys = {
aKey: joi.boolean(),
};
const config = {
validate: {
query: joi.object().keys(queryKeys),
},
};
whereMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
config,
});
const { query } = configForMethod.validate;
const keys = [
...Object.keys(queryKeys),
];
keys.forEach((key) => {
t.ifError(
query.validate({ [key]: true }).error
, `applies ${key} to validate.query`
);
});
t.truthy(
query.validate({ notAThing: true }).error
, 'errors on a non-valid key'
);
});
});
test('does not modify initial config on multiple passes', (t) => {
const { config } = t.context;
const originalConfig = { ...config };
whereMethods.forEach((method) => {
getConfigForMethod({ method, ...t.context });
});
t.deepEqual(
config
, originalConfig
, 'does not modify the original config object'
);
});

View File

@ -1,5 +1,5 @@
if (!global._babelPolyfill) {
require('babel/polyfill');
require('babel-polyfill');
}
import crud, { associations } from './crud';
@ -7,10 +7,11 @@ 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';
let db = server.plugins['hapi-sequelize'].db;
let models = db.sequelize.models;
const db = server.plugins['hapi-sequelize'][options.name];
const models = db.sequelize.models;
const onRequest = function (request, reply) {
const uri = request.raw.req.url;
@ -26,40 +27,41 @@ const register = (server, options = {}, next) => {
method: onRequest,
});
for (let modelName of Object.keys(models)) {
let model = models[modelName];
let { plural, singular } = model.options.name;
for (const modelName of Object.keys(models)) {
const model = models[modelName];
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];
const { source, target } = association;
for (let key of Object.keys(model.associations)) {
let association = model.associations[key];
let { source, target } = association;
let sourceName = source.options.name;
const sourceName = source.options.name;
const names = (rev) => {
const arr = [{
plural: sourceName.plural.toLowerCase(),
singular: sourceName.singular.toLowerCase(),
original: sourceName,
}, {
plural: association.options.name.plural.toLowerCase(),
singular: association.options.name.singular.toLowerCase(),
original: association.options.name,
}];
plural: sourceName.plural.toLowerCase(),
singular: sourceName.singular.toLowerCase(),
original: sourceName,
}, {
plural: association.options.name.plural.toLowerCase(),
singular: association.options.name.singular.toLowerCase(),
original: association.options.name,
}];
return rev ? { b: arr[0], a: arr[1] } : { a: arr[0], b: arr[1] };
};
let targetAssociations = target.associations[sourceName.plural] || target.associations[sourceName.singular];
let sourceType = association.associationType,
targetType = (targetAssociations || {}).associationType;
const targetAssociations = target.associations[sourceName.plural]
|| target.associations[sourceName.singular];
const sourceType = association.associationType,
targetType = (targetAssociations || {}).associationType;
try {
if (sourceType === 'BelongsTo' && (targetType === 'BelongsTo' || !targetType)) {
@ -83,13 +85,24 @@ const register = (server, options = {}, next) => {
associations.associate(server, source, target, names(), options);
associations.associate(server, target, source, names(1), options);
} catch(e) {
} catch (e) {
// There might be conflicts in case of models associated with themselves and some other
// rare cases.
}
}
}
// build the methods for each model now that we've defined all the
// associations
Object.keys(models).filter((modelName) => {
const model = models[modelName];
return model.options.name.singular === model.name;
}).forEach((modelName) => {
const model = models[modelName];
crud(server, model, options);
});
next();
};

View File

@ -1,22 +1,50 @@
import { omit } from 'lodash';
import { omit, identity, toNumber, isString, isUndefined } from 'lodash';
import { notImplemented } from 'boom';
const sequelizeKeys = ['include', 'order', 'limit', 'offset'];
const getModels = (request) => {
const noGetDb = typeof request.getDb !== 'function';
const noRequestModels = !request.models;
if (noGetDb && noRequestModels) {
return notImplemented('`request.getDb` or `request.models` are not defined.'
+ 'Be sure to load hapi-sequelize before hapi-sequelize-crud.');
}
const { models } = noGetDb ? request : request.getDb();
return models;
};
export const parseInclude = request => {
const include = Array.isArray(request.query.include) ? request.query.include
: [request.query.include];
const include = Array.isArray(request.query.include)
? request.query.include
: [request.query.include]
;
const models = getModels(request);
if (models.isBoom) return models;
return include.map(a => {
if (typeof a === 'string') return request.models[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) {
a.model = request.models[a.model];
a.model = models[a.model];
}
return a;
}).filter(a => a);
}).filter(identity);
};
export const parseWhere = request => {
const where = omit(request.query, 'include');
const where = omit(request.query, sequelizeKeys);
for (const key of Object.keys(where)) {
try {
@ -29,6 +57,54 @@ export const parseWhere = request => {
return where;
};
export const parseLimitAndOffset = (request) => {
const { limit, offset } = request.query;
const out = {};
if (!isUndefined(limit)) {
out.limit = toNumber(limit);
}
if (!isUndefined(offset)) {
out.offset = toNumber(offset);
}
return out;
};
const parseOrderArray = (order, models) => {
return order.map((requestColumn) => {
if (Array.isArray(requestColumn)) {
return parseOrderArray(requestColumn, models);
}
let column;
try {
column = JSON.parse(requestColumn);
} catch (e) {
column = requestColumn;
}
if (column.model) column.model = models[column.model];
return column;
});
};
export const parseOrder = (request) => {
const { order } = request.query;
if (!order) return null;
const models = getModels(request);
if (models.isBoom) return models;
// 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
const requestOrderColumns = isString(order) ? [order.split(' ')] : order;
const parsedOrder = parseOrderArray(requestOrderColumns, models);
return parsedOrder;
};
export const getMethod = (model, association, plural = true, method = 'get') => {
const a = plural ? association.original.plural : association.original.singular;
const b = plural ? association.original.singular : association.original.plural; // alternative

113
src/utils.test.js Normal file
View File

@ -0,0 +1,113 @@
import test from 'ava';
import { parseLimitAndOffset, parseOrder, parseWhere } from './utils.js';
test.beforeEach((t) => {
const models = t.context.models = { User: {} };
t.context.request = { query: {}, models };
});
test('parseLimitAndOffset is a function', (t) => {
t.is(typeof parseLimitAndOffset, 'function');
});
test('parseLimitAndOffset returns limit and offset', (t) => {
const { request } = t.context;
request.query.limit = 1;
request.query.offset = 2;
request.query.thing = 'hi';
t.is(
parseLimitAndOffset(request).limit
, request.query.limit
);
t.is(
parseLimitAndOffset(request).offset
, request.query.offset
);
});
test('parseLimitAndOffset returns limit and offset as numbers', (t) => {
const { request } = t.context;
const limit = 1;
const offset = 2;
request.query.limit = `${limit}`;
request.query.offset = `${offset}`;
request.query.thing = 'hi';
t.is(
parseLimitAndOffset(request).limit
, limit
);
t.is(
parseLimitAndOffset(request).offset
, offset
);
});
test('parseOrder is a function', (t) => {
t.is(typeof parseOrder, 'function');
});
test('parseOrder returns order when a string', (t) => {
const { request } = t.context;
const order = 'thing';
request.query.order = order;
request.query.thing = 'hi';
t.deepEqual(
parseOrder(request)
, [[order]]
);
});
test('parseOrder returns order when json', (t) => {
const { request,models } = t.context;
request.query.order = [JSON.stringify({ model: 'User' }), 'DESC'];
request.query.thing = 'hi';
t.deepEqual(
parseOrder(request)
, [{ model: models.User }, 'DESC']
);
});
test('parseOrder returns null when not defined', (t) => {
const { request } = t.context;
request.query.thing = 'hi';
t.is(
parseOrder(request)
, null
);
});
test('parseWhere is a function', (t) => {
t.is(typeof parseWhere, 'function');
});
test('parseWhere returns the non-sequelize keys', (t) => {
const { request } = t.context;
request.query.order = 'thing';
request.query.include = 'User';
request.query.limit = 2;
request.query.thing = 'hi';
t.deepEqual(
parseWhere(request)
, { thing: 'hi' }
);
});
test('parseWhere returns json converted keys', (t) => {
const { request } = t.context;
request.query.order = 'hi';
request.query.thing = '{"id": {"$in": [2, 3]}}';
t.deepEqual(
parseWhere(request)
, { thing: { id: { $in: [2, 3] } } }
);
});

18
test/fixtures/models/city.js vendored Normal file
View 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' },
});
},
},
});
};

39
test/fixtures/models/player.js vendored Normal file
View File

@ -0,0 +1,39 @@
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',
},
},
returnsAll: {
where: {
name: {
$ne: 'notaname',
},
},
},
},
});
};

22
test/fixtures/models/team.js vendored Normal file
View 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' },
});
},
},
});
};

74
test/integration-setup.js Normal file
View File

@ -0,0 +1,74 @@
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 team2 = await Team.create({ name: 'Footballs', cityId: city1.id });
const player1 = await Player.create({
name: 'Cat', teamId: team1.id, active: true,
});
const player2 = await Player.create({ name: 'Pinot', teamId: team1.id });
const player3 = await Player.create({ name: 'Syrah', teamId: team2.id });
t.context.instances = { city1, team1, team2, player1, player2, player3 };
});
// 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 };
};