54 Commits

Author SHA1 Message Date
9a231f0685 2.6.1 2016-09-08 13:22:29 -07:00
8cdfc5858d Merge pull request #24 from mdibaiee/use-json
Fix toJSON responses
2016-09-08 11:33:47 -07:00
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
85111c7dc8 2.6.0 2016-09-06 11:30:22 -07:00
196999a4c5 Merge pull request #20 from Getable/error-on-invalid-where
Validate query and payload
2016-09-06 11:29:40 -07:00
3e9f024dcf Test: now testing get-config-for-method 2016-09-06 11:25:03 -07:00
4c9ae36c5c Refactor: move get-config-for-method to a file 2016-09-06 11:24:41 -07:00
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
edccfb2316 Chore (deps) update Joi 7 → 9
Shouldn't impact us
2016-09-06 07:28:43 -07:00
0d8ab9f02e Chore (deps) update boom (major)
They just removed a method we don't use.
2016-09-06 07:28:43 -07:00
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
69221ea331 Feat query & payload now validated 2016-09-06 07:28:43 -07:00
f33c8da55d Fix (CRUD update) validate id 2016-09-06 07:28:43 -07:00
833df49173 Chore add comments for config creation 2016-09-06 07:28:43 -07:00
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
b35bd23c91 Fix: prefer user's config before our own 2016-09-06 07:28:43 -07:00
b4ea8c5b8e Docs: add more details for include and where 2016-09-06 07:28:43 -07:00
85cd2823da Docs: #cleanup and style fixes 2016-09-06 07:28:43 -07:00
e0132c2cae Fix: handle all parseInclude errors 2016-09-06 07:28:43 -07:00
bd18c57529 chore: put CircleCI badge in the same line as heading 2016-09-06 09:30:21 +04:30
3e53ba8d2c Merge branch 'master' of github.com:mdibaiee/hapi-sequelize-crud 2016-09-06 09:29:39 +04:30
81b704a395 chore: add CircleCI badge 2016-09-06 09:28:56 +04:30
d69b87b8fa Merge pull request #22 from mdibaiee/tests
Add Tests
2016-09-06 09:25:24 +04:30
03755f94c5 Test (CI) configure circle 2016-09-05 17:37:50 -07:00
7cecd7fb40 Test (list) add initial list tests 2016-09-05 17:12:13 -07:00
7b757fcc50 Fix (crud) if no prefix, things still work 2016-09-05 17:11:42 -07:00
de0685c8bb Chore: install and configure AVA 2016-09-05 17:10:58 -07:00
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
38ccb3adf6 Chore (deps) update eslint (major)
Breaking changes shouldn't affect us
2016-09-05 15:43:48 -07:00
506d42f39a 2.5.4 2016-09-05 12:30:15 -07:00
3dfa72ddee Merge pull request #21 from Getable/better-error
Fix a bug in error parsing
2016-09-05 12:25:14 -07:00
bab2e90cbb Feat (error) parse PG 42* errors 2016-09-04 17:28:16 -07:00
da6b3ce963 Feat (error) include hint on PG errors
Provides useful info!
2016-09-04 17:28:09 -07:00
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
79c6a81a3a chore(version): bump version 2016-08-30 09:49:07 +04:30
517f2b8157 Merge pull request #17 from Getable/fix-babel-polyfill
Fixes for babel-polyfill
2016-08-30 09:48:20 +04:30
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
ce6f1fedde Fix: bad merge in error.js 2016-08-28 09:57:23 -07:00
db86507ef9 Fix: correct babel-polyfill version
This installs the babel-polyfill for babel 6. #oops
2016-08-28 09:56:46 -07:00
6ad9df2db1 chore: bump version 2016-08-26 13:15:50 +04:30
17105f66f4 feat(errors): parse sequelize errors and use the relevant Boom error type 2016-08-26 13:15:15 +04:30
b18479e02e Merge pull request #16 from Getable/parse-sequelize-errors
Parse Sequelize errors
2016-08-26 13:11:29 +04:30
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
9524e55690 chore(version): bump version 2016-08-04 10:38:18 +04:30
1752d700f5 Merge pull request #15 from Getable/better-errors
Add: Better errors
2016-08-04 10:37:29 +04:30
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
0d6a715511 chore(CONTRIBUTING): a contribution guide, git commit messages and code linting
@joeybaker ^
2016-07-22 23:29:54 +04:30
e5d72fd034 chore(version): bump version to 2.5.0, we have a new feature 2016-07-22 23:25:55 +04:30
a0aeaef3a9 Merge pull request #14 from Getable/fix-permissions
Change: permissions must always be an array
2016-07-22 23:25:26 +04:30
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
fb8275abca Merge pull request #12 from Getable/rm-extraneous-babel
Build: rm extraneous babel dep
2016-07-22 21:59:26 +04:30
098aabfea5 Merge pull request #11 from Getable/add-permissions
Add: permissions
2016-07-22 21:58:36 +04:30
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
0416986896 Build: rm extraneous babel dep #oops 2016-07-22 09:52:21 -07:00
12 changed files with 724 additions and 89 deletions

View File

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

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,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
@ -11,6 +11,9 @@ npm install -S hapi-sequelize-crud
##Configure
Please note that you should register `hapi-sequelize-crud` after defining your
associations.
```javascript
// First, register hapi-sequelize
await register({
@ -29,15 +32,85 @@ await register({
options: {
prefix: '/v1',
name: 'db', // the same name you used for configuring `hapi-sequelize` (options.name)
defaultConfig: { ... } // passed as `config` to all routes created
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']}
]
}
});
```
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]})
```
## Full list of methods
Let's say you have a `many-to-many` association like this:
@ -52,8 +125,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}

9
circle.yml Normal file
View File

@ -0,0 +1,9 @@
machine:
node:
version: 6.5.0
dependencies:
pre:
- npm prune
post:
- mkdir -p $CIRCLE_TEST_REPORTS/ava

View File

@ -1,6 +1,6 @@
{
"name": "hapi-sequelize-crud",
"version": "2.4.0",
"version": "2.6.1",
"description": "Hapi plugin that automatically generates RESTful API for CRUD",
"main": "build/index.js",
"config": {
@ -9,8 +9,9 @@
}
},
"scripts": {
"lint": "eslint src test",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint src",
"test": "ava --require babel-register --source='*.test.js' --tap=${CI-false} | $(if [ -z ${CI:-} ]; then echo 'tail'; else tap-xunit > $CIRCLE_TEST_REPORTS/ava/ava.xml; fi;)",
"tdd": "ava --require babel-register --source='*.test.js' --watch",
"build": "scripty",
"watch": "scripty"
},
@ -23,21 +24,28 @@
"author": "Mahdi Dibaiee <mdibaiee@aol.com> (http://dibaiee.ir/)",
"license": "MIT",
"devDependencies": {
"babel-cli": "^6.10.1",
"ava": "^0.16.0",
"babel-cli": "^6.14.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.10.3",
"babel-preset-stage-1": "^6.5.0",
"eslint": "2.10.2",
"eslint-config-pichak": "1.1.0",
"ghooks": "1.0.3",
"scripty": "^1.6.0"
"babel-plugin-transform-es2015-modules-commonjs": "^6.14.0",
"babel-preset-stage-1": "^6.13.0",
"eslint": "^3.4.0",
"eslint-config-pichak": "^1.1.2",
"eslint-plugin-ava": "^3.0.0",
"ghooks": "^1.3.2",
"scripty": "^1.6.0",
"sinon": "^1.17.5",
"sinon-bluebird": "^3.0.2",
"tap-xunit": "^1.4.0"
},
"dependencies": {
"babel": "5.8.3",
"boom": "^3.2.2",
"joi": "7.2.1",
"lodash": "4.0.0"
"boom": "^4.0.0",
"joi": "^9.0.4",
"lodash": "^4.15.0"
},
"optionalDependencies": {
"babel-polyfill": "^6.13.0"
}
}

View File

@ -1,30 +1,136 @@
import joi from 'joi';
import path from 'path';
import error from './error';
import _ from 'lodash';
import { parseInclude, parseWhere } 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);
const createAll = ({
server,
model,
prefix,
config,
attributeValidation,
associationValidation,
}) => {
Object.keys(methods).forEach((method) => {
methods[method]({
server,
model,
prefix,
config: getConfigForMethod({
method,
attributeValidation,
associationValidation,
config,
}),
});
});
};
export const list = (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 modelAssociations = Object.keys(model.associations);
const attributeValidation = modelAttributes.reduce((params, attribute) => {
params[attribute] = joi.any();
return params;
}, {});
const associationValidation = {
include: joi.array().items(joi.string().valid(...modelAssociations)),
};
// if we don't have any permissions set, just create all the methods
if (!permissions) {
createAll({
server,
model,
prefix,
config,
attributeValidation,
associationValidation,
});
// 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,
});
// 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,
config: permissionConfig,
}),
});
});
} else {
createAll({
server,
model,
prefix,
attributeValidation,
associationValidation,
config: permissionConfig,
});
}
}
});
}
};
export const list = ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: `${prefix}/${model._plural}`,
path: path.join(prefix, model._plural),
@error
async handler(request, reply) {
@ -37,17 +143,17 @@ export const list = (server, model) => {
where, include,
});
reply(list);
reply(list.map((item) => item.toJSON()));
},
config: defaultConfig,
config,
});
};
export const get = (server, model) => {
export const get = ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: `${prefix}/${model._singular}/{id?}`,
path: path.join(prefix, model._singular, '{id?}'),
@error
async handler(request, reply) {
@ -56,68 +162,72 @@ export const get = (server, model) => {
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({
config: _.defaultsDeep(config, {
validate: {
params: joi.object().keys({
params: {
id: joi.any(),
}),
},
},
}, defaultConfig),
}),
});
};
export const scope = (server, model) => {
export const scope = ({ server, model, prefix = '/', config }) => {
const scopes = Object.keys(model.options.scopes);
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);
if (include instanceof Error) return void reply(include);
const list = await model.scope(request.params.scope).findAll({ include, where });
reply(list);
reply(list.map((item) => item.toJSON()));
},
config: _.defaultsDeep({
config: _.defaultsDeep(config, {
validate: {
params: joi.object().keys({
params: {
scope: joi.string().valid(...scopes),
}),
},
},
}, defaultConfig),
}),
});
};
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) {
@ -128,17 +238,18 @@ export const destroy = (server, model) => {
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) {
@ -148,69 +259,73 @@ export const destroyAll = (server, model) => {
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) => {
export const destroyScope = ({ server, model, prefix = '/', config }) => {
const scopes = Object.keys(model.options.scopes);
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);
if (include instanceof Error) return void reply(include);
const list = await model.scope(request.params.scope).findAll({ include, where });
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({
config: _.defaultsDeep(config, {
validate: {
params: joi.object().keys({
params: {
scope: joi.string().valid(...scopes),
}),
},
},
}, defaultConfig),
}),
});
};
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 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({
config: _.defaultsDeep(config, {
validate: {
payload: joi.object().required(),
params: {
id: joi.any(),
},
},
}, defaultConfig),
}),
});
};
import * as associations from './associations/index';
export { associations };
const methods = {
list, get, scope, create, destroy, destroyAll, destroyScope, update,
};

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

@ -0,0 +1,157 @@
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.model],
};
});
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.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'
);
});

View File

@ -1,3 +1,5 @@
import Boom from 'boom';
export default (target, key, descriptor) => {
const fn = descriptor.value;
@ -5,8 +7,59 @@ export default (target, key, descriptor) => {
try {
await fn(request, reply);
} catch (e) {
console.error(e);
reply(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,88 @@
import { defaultsDeep } from 'lodash';
import joi from 'joi';
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 default ({ method, attributeValidation, associationValidation, config = {} }) => {
const hasWhere = whereMethods.includes(method);
const hasInclude = includeMethods.includes(method);
const hasPayload = payloadMethods.includes(method);
const methodConfig = { ...config };
if (hasWhere) {
defaultsDeep(methodConfig, {
validate: {
query: {
...attributeValidation,
...sequelizeOperators,
},
},
});
}
if (hasInclude) {
defaultsDeep(methodConfig, {
validate: {
query: {
...associationValidation,
},
},
});
}
if (hasPayload) {
defaultsDeep(methodConfig, {
validate: {
payload: {
...attributeValidation,
},
},
});
}
return methodConfig;
};

View File

@ -0,0 +1,117 @@
import test from 'ava';
import joi from 'joi';
import
getConfigForMethod, {
whereMethods,
includeMethods,
payloadMethods,
sequelizeOperators,
} from './get-config-for-method.js';
test.beforeEach((t) => {
t.context.attributeValidation = {
myKey: joi.any(),
};
t.context.associationValidation = {
include: ['MyModel'],
};
t.context.config = {
cors: {},
};
});
test('get-config-for-method validate.query seqeulizeOperators', (t) => {
whereMethods.forEach((method) => {
const configForMethod = getConfigForMethod({ method });
const { query } = configForMethod.validate;
const configForMethodValidateQueryKeys = Object.keys(query);
t.truthy(
query,
`applies query validation for ${method}`
);
Object.keys(sequelizeOperators).forEach((operator) => {
t.truthy(
configForMethodValidateQueryKeys.includes(operator),
`applies sequelize operator "${operator}" in validate.where for ${method}`
);
});
});
});
test('get-config-for-method 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.truthy(
query[key]
, `applies attributeValidation (${key}) to validate.query`
);
});
});
});
test('get-config-for-method validate.query associationValidation', (t) => {
const { attributeValidation, associationValidation } = t.context;
includeMethods.forEach((method) => {
const configForMethod = getConfigForMethod({
method,
attributeValidation,
associationValidation,
});
const { query } = configForMethod.validate;
Object.keys(attributeValidation).forEach((key) => {
t.truthy(
query[key]
, `applies attributeValidation (${key}) to validate.query when include should be applied`
);
});
Object.keys(associationValidation).forEach((key) => {
t.truthy(
query[key]
, `applies associationValidation (${key}) to validate.query when include should be applied`
);
});
});
});
test('get-config-for-method 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.truthy(
payload[key]
, `applies attributeValidation (${key}) to validate.payload`
);
});
});
});
test('get-config-for-method does not modify initial config on multiple passes', (t) => {
const { config } = t.context;
const originalConfig = { ...config };
whereMethods.forEach((method) => {
getConfigForMethod({ method, config });
});
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';

View File

@ -1,4 +1,5 @@
import { omit, identity } from 'lodash';
import { notImplemented } from 'boom';
export const parseInclude = request => {
const include = Array.isArray(request.query.include) ? request.query.include
@ -8,7 +9,7 @@ export const parseInclude = request => {
const noRequestModels = !request.models;
if (noGetDb && noRequestModels) {
return new Error('`request.getDb` or `request.models` are not defined.'
return notImplemented('`request.getDb` or `request.models` are not defined.'
+ 'Be sure to load hapi-sequelize before hapi-sequelize-crud.');
}