7 Commits

Author SHA1 Message Date
a720e30a85 2.6.2 2016-09-08 13:27:20 -07:00
518c4a4226 2.6.1 2016-09-08 13:27:10 -07:00
469aaec66f Merge pull request #23 from mdibaiee/fix-joi-concat
Fix (validation) use joi's concat
2016-09-08 13:26:50 -07:00
8ee5661252 Test: only run src test files #oops 2016-09-08 13:24:08 -07:00
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
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
5 changed files with 506 additions and 96 deletions

View File

@ -1,6 +1,6 @@
{
"name": "hapi-sequelize-crud",
"version": "2.6.0",
"version": "2.6.2",
"description": "Hapi plugin that automatically generates RESTful API for CRUD",
"main": "build/index.js",
"config": {
@ -10,8 +10,8 @@
},
"scripts": {
"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",
"test": "ava --require babel-register --source='src/**/*.js' --source='!build/**/*' --tap=${CI-false} src/**/*.test.js | $(if [ -z ${CI:-} ]; then echo 'tail'; else tap-xunit > $CIRCLE_TEST_REPORTS/ava/ava.xml; fi;)",
"tdd": "ava --require babel-register --source='src/**/*.js' --source='!build/**/*' --watch src/**/*.test.js",
"build": "scripty",
"watch": "scripty"
},
@ -31,6 +31,7 @@
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-es2015-modules-commonjs": "^6.14.0",
"babel-preset-stage-1": "^6.13.0",
"babel-register": "^6.14.0",
"eslint": "^3.4.0",
"eslint-config-pichak": "^1.1.2",
"eslint-plugin-ava": "^3.0.0",

View File

@ -14,6 +14,7 @@ const createAll = ({
config,
attributeValidation,
associationValidation,
scopes,
}) => {
Object.keys(methods).forEach((method) => {
methods[method]({
@ -25,6 +26,7 @@ const createAll = ({
attributeValidation,
associationValidation,
config,
scopes,
}),
});
});
@ -64,6 +66,8 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
include: joi.array().items(joi.string().valid(...modelAssociations)),
};
const scopes = Object.keys(model.options.scopes);
// if we don't have any permissions set, just create all the methods
if (!permissions) {
createAll({
@ -73,6 +77,7 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
config,
attributeValidation,
associationValidation,
scopes,
});
// if permissions are set, but we can't parse them, throw an error
} else if (!Array.isArray(permissions)) {
@ -87,6 +92,7 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
config,
attributeValidation,
associationValidation,
scopes,
});
// if we've gotten here, we have complex permissions and need to set them
} else {
@ -108,6 +114,7 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
method,
attributeValidation,
associationValidation,
scopes,
config: permissionConfig,
}),
});
@ -119,6 +126,7 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
prefix,
attributeValidation,
associationValidation,
scopes,
config: permissionConfig,
});
}
@ -143,7 +151,7 @@ export const list = ({ server, model, prefix = '/', config }) => {
where, include,
});
reply(list);
reply(list.map((item) => item.toJSON()));
},
config,
@ -168,21 +176,13 @@ export const get = ({ server, model, prefix = '/', config }) => {
if (!instance) return void reply(notFound(`${id} not found.`));
reply(instance);
reply(instance.toJSON());
},
config: _.defaultsDeep(config, {
validate: {
params: {
id: joi.any(),
},
},
}),
config,
});
};
export const scope = ({ server, model, prefix = '/', config }) => {
const scopes = Object.keys(model.options.scopes);
server.route({
method: 'GET',
path: path.join(prefix, model._plural, '{scope}'),
@ -196,15 +196,9 @@ export const scope = ({ server, model, prefix = '/', config }) => {
const list = await model.scope(request.params.scope).findAll({ include, where });
reply(list);
reply(list.map((item) => item.toJSON()));
},
config: _.defaultsDeep(config, {
validate: {
params: {
scope: joi.string().valid(...scopes),
},
},
}),
config,
});
};
@ -217,7 +211,7 @@ export const create = ({ server, model, prefix = '/', config }) => {
async handler(request, reply) {
const instance = await model.create(request.payload);
reply(instance);
reply(instance.toJSON());
},
config,
@ -238,7 +232,8 @@ export const destroy = ({ server, model, prefix = '/', config }) => {
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,
@ -258,7 +253,8 @@ export const destroyAll = ({ server, model, prefix = '/', config }) => {
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,
@ -266,8 +262,6 @@ export const destroyAll = ({ server, model, prefix = '/', config }) => {
};
export const destroyScope = ({ server, model, prefix = '/', config }) => {
const scopes = Object.keys(model.options.scopes);
server.route({
method: 'DELETE',
path: path.join(prefix, model._plural, '{scope}'),
@ -283,15 +277,10 @@ export const destroyScope = ({ server, model, prefix = '/', config }) => {
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, {
validate: {
params: {
scope: joi.string().valid(...scopes),
},
},
}),
config,
});
};
@ -309,17 +298,10 @@ export const update = ({ server, model, prefix = '/', config }) => {
await instance.update(request.payload);
reply(instance);
reply(instance.toJSON());
},
config: _.defaultsDeep(config, {
validate: {
payload: joi.object().required(),
params: {
id: joi.any(),
},
},
}),
config,
});
};

View File

@ -1,6 +1,7 @@
import test from 'ava';
import { list } from './crud.js';
import { stub } from 'sinon';
import uniqueId from 'lodash/uniqueId.js';
import 'sinon-bluebird';
const METHODS = {
@ -13,12 +14,23 @@ test.beforeEach('setup server', (t) => {
};
});
test.beforeEach('setup model', (t) => {
t.context.model = {
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) => {
@ -93,12 +105,11 @@ test('crud#list config', (t) => {
});
test('crud#list handler', async (t) => {
const { server, model, request, reply } = t.context;
const allModels = [{ id: 1 }, { id: 2 }];
const { server, model, request, reply, models } = t.context;
list({ server, model });
const { handler } = server.route.args[0][0];
model.findAll.resolves(allModels);
model.findAll.resolves(models);
try {
await handler(request, reply);
@ -115,9 +126,9 @@ test('crud#list handler', async (t) => {
const response = reply.args[0][0];
t.is(
t.deepEqual(
response,
allModels,
models.map(({ id }) => ({ id })),
'responds with the list of models'
);
});

View File

@ -1,6 +1,15 @@
import { defaultsDeep } from 'lodash';
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(),
@ -47,41 +56,81 @@ export const payloadMethods = [
'update',
];
export default ({ method, attributeValidation, associationValidation, config = {} }) => {
export const scopeParamsMethods = [
'destroyScope',
'scope',
];
export const idParamsMethods = [
'get',
'update',
];
export default ({
method, attributeValidation, associationValidation, scopes = [], config = {},
}) => {
const hasWhere = whereMethods.includes(method);
const hasInclude = includeMethods.includes(method);
const hasPayload = payloadMethods.includes(method);
const methodConfig = { ...config };
const hasScopeParams = scopeParamsMethods.includes(method);
const hasIdParams = idParamsMethods.includes(method);
// clone the config so we don't modify it on multiple passes.
let methodConfig = { ...config, validate: { ...config.validate } };
if (hasWhere) {
defaultsDeep(methodConfig, {
validate: {
query: {
...attributeValidation,
...sequelizeOperators,
},
},
});
const query = concatToJoiObject(joi.object()
.keys({
...attributeValidation,
...sequelizeOperators,
}),
get(methodConfig, 'validate.query')
);
methodConfig = set(methodConfig, 'validate.query', query);
}
if (hasInclude) {
defaultsDeep(methodConfig, {
validate: {
query: {
...associationValidation,
},
},
});
const query = concatToJoiObject(joi.object()
.keys({
...associationValidation,
}),
get(methodConfig, 'validate.query')
);
methodConfig = set(methodConfig, 'validate.query', query);
}
if (hasPayload) {
defaultsDeep(methodConfig, {
validate: {
payload: {
...attributeValidation,
},
},
});
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);
}
return methodConfig;

View File

@ -5,16 +5,22 @@ import
whereMethods,
includeMethods,
payloadMethods,
scopeParamsMethods,
idParamsMethods,
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: ['MyModel'],
include: joi.array().items(joi.string().valid(t.context.models)),
};
t.context.config = {
@ -22,11 +28,10 @@ test.beforeEach((t) => {
};
});
test('get-config-for-method validate.query seqeulizeOperators', (t) => {
test('validate.query seqeulizeOperators', (t) => {
whereMethods.forEach((method) => {
const configForMethod = getConfigForMethod({ method });
const { query } = configForMethod.validate;
const configForMethodValidateQueryKeys = Object.keys(query);
t.truthy(
query,
@ -34,15 +39,20 @@ test('get-config-for-method validate.query seqeulizeOperators', (t) => {
);
Object.keys(sequelizeOperators).forEach((operator) => {
t.truthy(
configForMethodValidateQueryKeys.includes(operator),
`applies sequelize operator "${operator}" in validate.where for ${method}`
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('get-config-for-method validate.query attributeValidation', (t) => {
test('validate.query attributeValidation', (t) => {
const { attributeValidation } = t.context;
whereMethods.forEach((method) => {
@ -50,16 +60,96 @@ test('get-config-for-method validate.query attributeValidation', (t) => {
const { query } = configForMethod.validate;
Object.keys(attributeValidation).forEach((key) => {
t.truthy(
query[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('get-config-for-method validate.query associationValidation', (t) => {
const { attributeValidation, associationValidation } = t.context;
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({
@ -70,22 +160,106 @@ test('get-config-for-method validate.query associationValidation', (t) => {
const { query } = configForMethod.validate;
Object.keys(attributeValidation).forEach((key) => {
t.truthy(
query[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.truthy(
query[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('get-config-for-method validate.payload associationValidation', (t) => {
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) => {
@ -93,20 +267,213 @@ test('get-config-for-method validate.payload associationValidation', (t) => {
const { payload } = configForMethod.validate;
Object.keys(attributeValidation).forEach((key) => {
t.truthy(
payload[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('get-config-for-method does not modify initial config on multiple passes', (t) => {
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('does not modify initial config on multiple passes', (t) => {
const { config } = t.context;
const originalConfig = { ...config };
whereMethods.forEach((method) => {
getConfigForMethod({ method, config });
getConfigForMethod({ method, ...t.context });
});
t.deepEqual(