Merge pull request #20 from Getable/error-on-invalid-where

Validate query and payload
This commit is contained in:
Joey Baker 2016-09-06 11:29:40 -07:00 committed by GitHub
commit 196999a4c5
5 changed files with 359 additions and 45 deletions

View File

@ -11,6 +11,9 @@ npm install -S hapi-sequelize-crud
##Configure ##Configure
Please note that you should register `hapi-sequelize-crud` after defining your
associations.
```javascript ```javascript
// First, register hapi-sequelize // First, register hapi-sequelize
await register({ await register({
@ -35,6 +38,7 @@ await register({
// `models` property. If you omit this property, all models will have // `models` property. If you omit this property, all models will have
// models defined for them. e.g. // models defined for them. e.g.
models: ['cat', 'dog'] // only the cat and dog models will have routes created models: ['cat', 'dog'] // only the cat and dog models will have routes created
// or // or
models: [ models: [
// possible methods: list, get, scope, create, destroy, destroyAll, destroyScope, update // possible methods: list, get, scope, create, destroy, destroyAll, destroyScope, update
@ -54,20 +58,59 @@ await register({
``` ```
### Methods ### Methods
* list: get all rows in a table * **list**: get all rows in a table
* get: get a single row * **get**: get a single row
* scope: reference a [sequelize scope](http://docs.sequelizejs.com/en/latest/api/model/#scopeoptions-model) * **scope**: reference a [sequelize scope](http://docs.sequelizejs.com/en/latest/api/model/#scopeoptions-model)
* create: create a new row * **create**: create a new row
* destroy: delete a row * **destroy**: delete a row
* destroyAll: delete all models in the table * **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 * **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 * **update**: update a row
## `where` queries
It's easy to restrict your requests using Sequelize's `where` query option. Just pass a query parameter.
Please note that you should register `hapi-sequelize-crud` after defining your ```js
associations. // returns only teams that have a `city` property of "windsor"
// GET /team?city=windsor
##What do I get // 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: Let's say you have a `many-to-many` association like this:
@ -82,8 +125,9 @@ You get these:
# get an array of records # get an array of records
GET /team/{id}/roles GET /team/{id}/roles
GET /role/{id}/teams 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?members=5
GET /role/{id}/teams?city=healdsburg
# you might also use scopes # you might also use scopes
GET /teams/{scope}/roles/{scope} GET /teams/{scope}/roles/{scope}

View File

@ -25,25 +25,25 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"ava": "^0.16.0", "ava": "^0.16.0",
"babel-cli": "^6.10.1", "babel-cli": "^6.14.0",
"babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-closure-elimination": "^1.0.6", "babel-plugin-closure-elimination": "^1.0.6",
"babel-plugin-transform-decorators-legacy": "^1.3.4", "babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-es2015-modules-commonjs": "^6.10.3", "babel-plugin-transform-es2015-modules-commonjs": "^6.14.0",
"babel-preset-stage-1": "^6.5.0", "babel-preset-stage-1": "^6.13.0",
"eslint": "^3.4.0", "eslint": "^3.4.0",
"eslint-config-pichak": "1.1.0", "eslint-config-pichak": "^1.1.2",
"eslint-plugin-ava": "^3.0.0", "eslint-plugin-ava": "^3.0.0",
"ghooks": "1.0.3", "ghooks": "^1.3.2",
"scripty": "^1.6.0", "scripty": "^1.6.0",
"sinon": "^1.17.5", "sinon": "^1.17.5",
"sinon-bluebird": "^3.0.2", "sinon-bluebird": "^3.0.2",
"tap-xunit": "^1.4.0" "tap-xunit": "^1.4.0"
}, },
"dependencies": { "dependencies": {
"boom": "^3.2.2", "boom": "^4.0.0",
"joi": "7.2.1", "joi": "^9.0.4",
"lodash": "4.0.0" "lodash": "^4.15.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"babel-polyfill": "^6.13.0" "babel-polyfill": "^6.13.0"

View File

@ -5,10 +5,28 @@ import _ from 'lodash';
import { parseInclude, parseWhere } from './utils'; import { parseInclude, parseWhere } from './utils';
import { notFound } from 'boom'; import { notFound } from 'boom';
import * as associations from './associations/index'; import * as associations from './associations/index';
import getConfigForMethod from './get-config-for-method.js';
const createAll = ({ server, model, prefix, config }) => { const createAll = ({
server,
model,
prefix,
config,
attributeValidation,
associationValidation,
}) => {
Object.keys(methods).forEach((method) => { Object.keys(methods).forEach((method) => {
methods[method]({ server, model, prefix, config }); methods[method]({
server,
model,
prefix,
config: getConfigForMethod({
method,
attributeValidation,
associationValidation,
config,
}),
});
}); });
}; };
@ -34,13 +52,43 @@ models: {
export default (server, model, { prefix, defaultConfig: config, models: permissions }) => { export default (server, model, { prefix, defaultConfig: config, models: permissions }) => {
const modelName = model._singular; 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) { if (!permissions) {
createAll({ server, model, prefix, config }); 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)) { } else if (!Array.isArray(permissions)) {
throw new Error('hapi-sequelize-crud: `models` property must be an array'); 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)) { } else if (permissions.includes(modelName)) {
createAll({ server, model, prefix, config }); createAll({
server,
model,
prefix,
config,
attributeValidation,
associationValidation,
});
// if we've gotten here, we have complex permissions and need to set them
} else { } else {
const permissionOptions = permissions.filter((permission) => { const permissionOptions = permissions.filter((permission) => {
return permission.model === modelName; return permission.model === modelName;
@ -56,11 +104,23 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
server, server,
model, model,
prefix, prefix,
config: permissionConfig, config: getConfigForMethod({
method,
attributeValidation,
associationValidation,
config: permissionConfig,
}),
}); });
}); });
} else { } else {
createAll({ server, model, prefix, config: permissionConfig }); createAll({
server,
model,
prefix,
attributeValidation,
associationValidation,
config: permissionConfig,
});
} }
} }
}); });
@ -102,19 +162,21 @@ export const get = ({ server, model, prefix = '/', config }) => {
const { id } = request.params; const { id } = request.params;
if (id) where[model.primaryKeyField] = id; if (id) where[model.primaryKeyField] = id;
if (include instanceof Error) return void reply(include);
const instance = await model.findOne({ where, include }); const instance = await model.findOne({ where, include });
if (!instance) return void reply(notFound(`${id} not found.`)); if (!instance) return void reply(notFound(`${id} not found.`));
reply(instance); reply(instance);
}, },
config: _.defaultsDeep({ config: _.defaultsDeep(config, {
validate: { validate: {
params: joi.object().keys({ params: {
id: joi.any(), id: joi.any(),
}), },
}, },
}, config), }),
}); });
}; };
@ -130,17 +192,19 @@ export const scope = ({ server, model, prefix = '/', config }) => {
const include = parseInclude(request); const include = parseInclude(request);
const where = parseWhere(request); const where = parseWhere(request);
if (include instanceof Error) return void reply(include);
const list = await model.scope(request.params.scope).findAll({ include, where }); const list = await model.scope(request.params.scope).findAll({ include, where });
reply(list); reply(list);
}, },
config: _.defaultsDeep({ config: _.defaultsDeep(config, {
validate: { validate: {
params: joi.object().keys({ params: {
scope: joi.string().valid(...scopes), scope: joi.string().valid(...scopes),
}), },
}, },
}, config), }),
}); });
}; };
@ -213,19 +277,21 @@ export const destroyScope = ({ server, model, prefix = '/', config }) => {
const include = parseInclude(request); const include = parseInclude(request);
const where = parseWhere(request); const where = parseWhere(request);
if (include instanceof Error) return void reply(include);
const list = await model.scope(request.params.scope).findAll({ include, where }); const list = await model.scope(request.params.scope).findAll({ include, where });
await Promise.all(list.map(instance => instance.destroy())); await Promise.all(list.map(instance => instance.destroy()));
reply(list); reply(list);
}, },
config: _.defaultsDeep({ config: _.defaultsDeep(config, {
validate: { validate: {
params: joi.object().keys({ params: {
scope: joi.string().valid(...scopes), scope: joi.string().valid(...scopes),
}), },
}, },
}, config), }),
}); });
}; };
@ -237,11 +303,7 @@ export const update = ({ server, model, prefix = '/', config }) => {
@error @error
async handler(request, reply) { async handler(request, reply) {
const { id } = request.params; const { id } = request.params;
const instance = await model.findOne({ const instance = await model.findById(id);
where: {
id,
},
});
if (!instance) return void reply(notFound(`${id} not found.`)); if (!instance) return void reply(notFound(`${id} not found.`));
@ -250,11 +312,14 @@ export const update = ({ server, model, prefix = '/', config }) => {
reply(instance); reply(instance);
}, },
config: _.defaultsDeep({ config: _.defaultsDeep(config, {
validate: { validate: {
payload: joi.object().required(), payload: joi.object().required(),
params: {
id: joi.any(),
},
}, },
}, config), }),
}); });
}; };

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