14 Commits

Author SHA1 Message Date
548a6ecd98 2.7.1 2016-10-26 11:43:53 -07:00
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
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
07176018b7 Fix(crud) include param can be a string or array 2016-10-26 10:59:02 -07:00
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
e318948fe4 2.7.0 2016-10-21 11:11:24 -07:00
d35b616a13 Merge pull request #25 from mdibaiee/add-filters
Feat add support of limit, offset, order
2016-10-21 11:10:35 -07:00
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
5923f0dbcb Test(crud) ensure list doesn't error 2016-10-20 17:20:22 -07:00
adb1d71984 Chore(deps) update patches and minors 2016-10-20 17:20:22 -07:00
3c516aa604 Chore gitignore mac junk files 2016-09-28 21:16:38 -07:00
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
f403e214a9 Docs show how to interact with hapi hooks
Fixes #3
2016-09-19 21:47:12 -07:00
71e6390282 Docs: add "modify the response format" section 2016-09-13 19:44:36 -07:00
11 changed files with 484 additions and 27 deletions

View File

@ -10,5 +10,5 @@
"transform-decorators-legacy", "transform-decorators-legacy",
"transform-es2015-modules-commonjs" "transform-es2015-modules-commonjs"
], ],
"sourceMaps": true "sourceMaps": "inline"
} }

3
.gitignore vendored
View File

@ -33,3 +33,6 @@ node_modules
# Debug log from npm # Debug log from npm
npm-debug.log npm-debug.log
# System
.DS_Store

View File

@ -9,7 +9,7 @@ This plugin depends on [`hapi-sequelize`](https://github.com/danecando/hapi-sequ
npm install -S hapi-sequelize-crud npm install -S hapi-sequelize-crud
``` ```
##Configure ## Configure
Please note that you should register `hapi-sequelize-crud` after defining your Please note that you should register `hapi-sequelize-crud` after defining your
associations. associations.
@ -52,6 +52,20 @@ await register({
// `config` if provided, overrides the default config // `config` if provided, overrides the default config
{model: 'bat', methods: ['list'], config: { ... }}, {model: 'bat', methods: ['list'], config: { ... }},
{model: 'bat', methods: ['create']} {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
}
}}
] ]
} }
}); });
@ -95,7 +109,7 @@ Getting related models is easy, just use a query parameter `include`.
```js ```js
// returns all teams with their related City model // returns all teams with their related City model
// GET /teams?include=City // GET /teams?include=city
// results in a Sequelize query: // results in a Sequelize query:
Team.findAll({include: City}) Team.findAll({include: City})
@ -104,12 +118,84 @@ Team.findAll({include: City})
If you want to get multiple related models, just pass multiple `include` parameters. If you want to get multiple related models, just pass multiple `include` parameters.
```js ```js
// returns all teams with their related City and Uniform models // returns all teams with their related City and Uniform models
// GET /teams?include=City&include=Uniform // GET /teams?include[]=city&include[]=uniform
// results in a Sequelize query: // results in a Sequelize query:
Team.findAll({include: [City, Uniform]}) 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
// 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']]})
```
## 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 ## 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:

View File

@ -1,6 +1,6 @@
{ {
"name": "hapi-sequelize-crud", "name": "hapi-sequelize-crud",
"version": "2.6.2", "version": "2.7.1",
"description": "Hapi plugin that automatically generates RESTful API for CRUD", "description": "Hapi plugin that automatically generates RESTful API for CRUD",
"main": "build/index.js", "main": "build/index.js",
"config": { "config": {
@ -25,26 +25,26 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"ava": "^0.16.0", "ava": "^0.16.0",
"babel-cli": "^6.14.0", "babel-cli": "^6.16.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.14.0", "babel-plugin-transform-es2015-modules-commonjs": "^6.16.0",
"babel-preset-stage-1": "^6.13.0", "babel-preset-stage-1": "^6.16.0",
"babel-register": "^6.14.0", "babel-register": "^6.16.3",
"eslint": "^3.4.0", "eslint": "^3.8.1",
"eslint-config-pichak": "^1.1.2", "eslint-config-pichak": "^1.1.2",
"eslint-plugin-ava": "^3.0.0", "eslint-plugin-ava": "^3.1.1",
"ghooks": "^1.3.2", "ghooks": "^1.3.2",
"scripty": "^1.6.0", "scripty": "^1.6.0",
"sinon": "^1.17.5", "sinon": "^1.17.6",
"sinon-bluebird": "^3.0.2", "sinon-bluebird": "^3.1.0",
"tap-xunit": "^1.4.0" "tap-xunit": "^1.4.0"
}, },
"dependencies": { "dependencies": {
"boom": "^4.0.0", "boom": "^4.2.0",
"joi": "^9.0.4", "joi": "^9.2.0",
"lodash": "^4.15.0" "lodash": "^4.16.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"babel-polyfill": "^6.13.0" "babel-polyfill": "^6.13.0"

View File

@ -2,7 +2,7 @@ import joi from 'joi';
import path from 'path'; import path from 'path';
import error from './error'; import error from './error';
import _ from 'lodash'; import _ from 'lodash';
import { parseInclude, parseWhere } from './utils'; import { parseInclude, parseWhere, parseLimitAndOffset, parseOrder } 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'; import getConfigForMethod from './get-config-for-method.js';
@ -55,15 +55,24 @@ 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 modelAttributes = Object.keys(model.attributes);
const modelAssociations = Object.keys(model.associations); const associatedModelNames = Object.keys(model.associations);
const modelAssociations = [
...associatedModelNames,
..._.flatMap(associatedModelNames, (associationName) => {
const { target } = model.associations[associationName];
const { _singular, _plural, _Singular, _Plural } = target;
return [_singular, _plural, _Singular, _Plural];
}),
];
const attributeValidation = modelAttributes.reduce((params, attribute) => { const attributeValidation = modelAttributes.reduce((params, attribute) => {
params[attribute] = joi.any(); params[attribute] = joi.any();
return params; return params;
}, {}); }, {});
const validAssociations = joi.string().valid(...modelAssociations);
const associationValidation = { const associationValidation = {
include: joi.array().items(joi.string().valid(...modelAssociations)), include: [joi.array().items(validAssociations), validAssociations],
}; };
const scopes = Object.keys(model.options.scopes); const scopes = Object.keys(model.options.scopes);
@ -144,11 +153,13 @@ export const list = ({ server, model, prefix = '/', config }) => {
async handler(request, reply) { async handler(request, reply) {
const include = parseInclude(request); const include = parseInclude(request);
const where = parseWhere(request); const where = parseWhere(request);
const { limit, offset } = parseLimitAndOffset(request);
const order = parseOrder(request);
if (include instanceof Error) return void reply(include); if (include instanceof Error) return void reply(include);
const list = await model.findAll({ const list = await model.findAll({
where, include, where, include, limit, offset, order,
}); });
reply(list.map((item) => item.toJSON())); reply(list.map((item) => item.toJSON()));
@ -191,10 +202,14 @@ export const scope = ({ server, model, prefix = '/', config }) => {
async handler(request, reply) { async handler(request, reply) {
const include = parseInclude(request); const include = parseInclude(request);
const where = parseWhere(request); const where = parseWhere(request);
const { limit, offset } = parseLimitAndOffset(request);
const order = parseOrder(request);
if (include instanceof Error) return void reply(include); 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, limit, offset, order,
});
reply(list.map((item) => item.toJSON())); reply(list.map((item) => item.toJSON()));
}, },

View File

@ -37,7 +37,7 @@ test.beforeEach('setup request stub', (t) => {
t.context.request = { t.context.request = {
query: {}, query: {},
payload: {}, payload: {},
models: [t.context.model], models: t.context.models,
}; };
}); });
@ -126,6 +126,8 @@ test('crud#list handler', async (t) => {
const response = reply.args[0][0]; const response = reply.args[0][0];
t.falsy(response instanceof Error, response);
t.deepEqual( t.deepEqual(
response, response,
models.map(({ id }) => ({ id })), models.map(({ id }) => ({ id })),
@ -155,3 +157,75 @@ test('crud#list handler if parseInclude errors', async (t) => {
'responds with a Boom error' '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

@ -66,6 +66,11 @@ export const idParamsMethods = [
'update', 'update',
]; ];
export const restrictMethods = [
'list',
'scope',
];
export default ({ export default ({
method, attributeValidation, associationValidation, scopes = [], config = {}, method, attributeValidation, associationValidation, scopes = [], config = {},
}) => { }) => {
@ -74,6 +79,7 @@ export default ({
const hasPayload = payloadMethods.includes(method); const hasPayload = payloadMethods.includes(method);
const hasScopeParams = scopeParamsMethods.includes(method); const hasScopeParams = scopeParamsMethods.includes(method);
const hasIdParams = idParamsMethods.includes(method); const hasIdParams = idParamsMethods.includes(method);
const hasRestrictMethods = restrictMethods.includes(method);
// clone the config so we don't modify it on multiple passes. // clone the config so we don't modify it on multiple passes.
let methodConfig = { ...config, validate: { ...config.validate } }; let methodConfig = { ...config, validate: { ...config.validate } };
@ -133,5 +139,18 @@ export default ({
methodConfig = set(methodConfig, 'validate.params', 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(),
}),
get(methodConfig, 'validate.query')
);
methodConfig = set(methodConfig, 'validate.query', query);
}
return methodConfig; return methodConfig;
}; };

View File

@ -7,6 +7,7 @@ import
payloadMethods, payloadMethods,
scopeParamsMethods, scopeParamsMethods,
idParamsMethods, idParamsMethods,
restrictMethods,
sequelizeOperators, sequelizeOperators,
} from './get-config-for-method.js'; } from './get-config-for-method.js';
@ -468,6 +469,101 @@ test('validate.payload idParamsMethods', (t) => {
}); });
}); });
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) => { test('does not modify initial config on multiple passes', (t) => {
const { config } = t.context; const { config } = t.context;
const originalConfig = { ...config }; const originalConfig = { ...config };

View File

@ -32,11 +32,12 @@ const register = (server, options = {}, next) => {
const { plural, singular } = model.options.name; const { plural, singular } = model.options.name;
model._plural = plural.toLowerCase(); model._plural = plural.toLowerCase();
model._singular = singular.toLowerCase(); model._singular = singular.toLowerCase();
model._Plural = plural;
model._Singular = singular;
// Join tables // Join tables
if (model.options.name.singular !== model.name) continue; if (model.options.name.singular !== model.name) continue;
crud(server, model, options);
for (const key of Object.keys(model.associations)) { for (const key of Object.keys(model.associations)) {
const association = model.associations[key]; const association = model.associations[key];
@ -92,6 +93,13 @@ const register = (server, options = {}, next) => {
} }
} }
// build the methods for each model now that we've defined all the
// associations
Object.keys(models).forEach((modelName) => {
const model = models[modelName];
crud(server, model, options);
});
next(); next();
}; };

View File

@ -1,9 +1,13 @@
import { omit, identity } from 'lodash'; import { omit, identity, toNumber, isString, isUndefined } from 'lodash';
import { notImplemented } from 'boom'; import { notImplemented } from 'boom';
const sequelizeKeys = ['include', 'order', 'limit', 'offset'];
export const parseInclude = request => { export const parseInclude = request => {
const include = Array.isArray(request.query.include) ? request.query.include const include = Array.isArray(request.query.include)
: [request.query.include]; ? request.query.include
: [request.query.include]
;
const noGetDb = typeof request.getDb !== 'function'; const noGetDb = typeof request.getDb !== 'function';
const noRequestModels = !request.models; const noRequestModels = !request.models;
@ -16,6 +20,13 @@ export const parseInclude = request => {
const { models } = noGetDb ? request : request.getDb(); const { models } = noGetDb ? request : request.getDb();
return include.map(a => { return include.map(a => {
const singluarOrPluralMatch = Object.keys(models).find((modelName) => {
const { _singular, _plural } = models[modelName];
return _singular === a || _plural === a;
});
if (singluarOrPluralMatch) return models[singluarOrPluralMatch];
if (typeof a === 'string') return models[a]; if (typeof a === 'string') return models[a];
if (a && typeof a.model === 'string' && a.model.length) { if (a && typeof a.model === 'string' && a.model.length) {
@ -27,7 +38,7 @@ export const parseInclude = request => {
}; };
export const parseWhere = request => { export const parseWhere = request => {
const where = omit(request.query, 'include'); const where = omit(request.query, sequelizeKeys);
for (const key of Object.keys(where)) { for (const key of Object.keys(where)) {
try { try {
@ -40,6 +51,38 @@ export const parseWhere = request => {
return where; 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;
};
export const parseOrder = (request) => {
const { order } = request.query;
if (!order) return null;
// transform to an array so sequelize will escape the input for us and
// maintain security. See http://docs.sequelizejs.com/en/latest/docs/querying/#ordering
if (isString(order)) return order.split(' ');
for (const key of Object.keys(order)) {
try {
order[key] = JSON.parse(order[key]);
} catch (e) {
//
}
}
return order;
};
export const getMethod = (model, association, plural = true, method = 'get') => { export const getMethod = (model, association, plural = true, method = 'get') => {
const a = plural ? association.original.plural : association.original.singular; const a = plural ? association.original.plural : association.original.singular;
const b = plural ? association.original.singular : association.original.plural; // alternative 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) => {
t.context.request = { query: {} };
});
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 } = t.context;
const order = [{ model: 'User' }, 'DESC'];
request.query.order = [JSON.stringify({ model: 'User' }), 'DESC'];
request.query.thing = 'hi';
t.deepEqual(
parseOrder(request)
, order
);
});
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] } } }
);
});