Added support for include relationship alias (as) and nested includes, fixed wrong joi json validation implementation inside parseInclude() function, fixed some associated routes resulted 404 because of prefix option

This commit is contained in:
Muhammad Labib Ramadhan 2016-11-10 01:46:42 +07:00
parent 6fa9e90ec5
commit e632f79e2b
8 changed files with 202 additions and 80 deletions

View File

@ -19,14 +19,14 @@ export default (server, a, b, names, options) => {
update(server, a, b, names);
};
export const get = (server, a, b, names) => {
export const get = async (server, a, b, names) => {
server.route({
method: 'GET',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const base = await a.findOne({
where: {
@ -51,14 +51,14 @@ export const get = (server, a, b, names) => {
});
};
export const list = (server, a, b, names) => {
export const list = async (server, a, b, names) => {
server.route({
method: 'GET',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.plural}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -77,16 +77,16 @@ export const list = (server, a, b, names) => {
});
};
export const scope = (server, a, b, names) => {
export const scope = async (server, a, b, names) => {
const scopes = Object.keys(b.options.scopes);
server.route({
method: 'GET',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}/{scope}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.plural}/{scope}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -116,7 +116,7 @@ export const scope = (server, a, b, names) => {
});
};
export const scopeScope = (server, a, b, names) => {
export const scopeScope = async (server, a, b, names) => {
const scopes = {
a: Object.keys(a.options.scopes),
b: Object.keys(b.options.scopes),
@ -124,11 +124,11 @@ export const scopeScope = (server, a, b, names) => {
server.route({
method: 'GET',
path: `${prefix}/${names.a.plural}/{scopea}/${names.b.plural}/{scopeb}`,
path: `${prefix}${names.a.plural}/{scopea}/${names.b.plural}/{scopeb}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const list = await b.scope(request.params.scopeb).findAll({
@ -152,14 +152,14 @@ export const scopeScope = (server, a, b, names) => {
});
};
export const destroy = (server, a, b, names) => {
export const destroy = async (server, a, b, names) => {
server.route({
method: 'DELETE',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.plural}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -179,16 +179,16 @@ export const destroy = (server, a, b, names) => {
});
};
export const destroyScope = (server, a, b, names) => {
export const destroyScope = async (server, a, b, names) => {
const scopes = Object.keys(b.options.scopes);
server.route({
method: 'DELETE',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}/{scope}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.plural}/{scope}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -221,14 +221,14 @@ export const destroyScope = (server, a, b, names) => {
});
};
export const update = (server, a, b, names) => {
export const update = async (server, a, b, names) => {
server.route({
method: 'PUT',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.plural}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.plural}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({

View File

@ -14,14 +14,14 @@ export default (server, a, b, names, options) => {
update(server, a, b, names);
};
export const get = (server, a, b, names) => {
export const get = async (server, a, b, names) => {
server.route({
method: 'GET',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.singular}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -47,7 +47,7 @@ export const get = (server, a, b, names) => {
export const create = (server, a, b, names) => {
server.route({
method: 'POST',
path: `${prefix}/${names.a.singular}/{id}/${names.b.singular}`,
path: `${prefix}${names.a.singular}/{id}/${names.b.singular}`,
@error
async handler(request, reply) {
@ -67,14 +67,14 @@ export const create = (server, a, b, names) => {
});
};
export const destroy = (server, a, b, names) => {
export const destroy = async (server, a, b, names) => {
server.route({
method: 'DELETE',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({
@ -99,11 +99,11 @@ export const destroy = (server, a, b, names) => {
export const update = (server, a, b, names) => {
server.route({
method: 'PUT',
path: `${prefix}/${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
path: `${prefix}${names.a.singular}/{aid}/${names.b.singular}/{bid}`,
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const base = await a.findOne({

View File

@ -6,7 +6,7 @@ const STATUS_OK = 200;
setup(test);
test('belongsTo /team?include=city', async (t) => {
test('belongsTo /team?include=city', async(t) => {
const { server, instances } = t.context;
const { team1, city1 } = instances;
const path = `/team/${team1.id}?include=city`;
@ -17,7 +17,7 @@ test('belongsTo /team?include=city', async (t) => {
t.is(result.City.id, city1.id);
});
test('belongsTo /team?include=cities', async (t) => {
test('belongsTo /team?include=cities', async(t) => {
const { server, instances } = t.context;
const { team1, city1 } = instances;
const path = `/team/${team1.id}?include=cities`;
@ -28,7 +28,7 @@ test('belongsTo /team?include=cities', async (t) => {
t.is(result.City.id, city1.id);
});
test('hasMany /team?include=player', async (t) => {
test('hasMany /team?include=player', async(t) => {
const { server, instances } = t.context;
const { team1, player1, player2 } = instances;
const path = `/team/${team1.id}?include=player`;
@ -42,7 +42,7 @@ test('hasMany /team?include=player', async (t) => {
t.truthy(playerIds.includes(player2.id));
});
test('hasMany /team?include=players', async (t) => {
test('hasMany /team?include=players', async(t) => {
const { server, instances } = t.context;
const { team1, player1, player2 } = instances;
const path = `/team/${team1.id}?include=players`;
@ -56,7 +56,18 @@ test('hasMany /team?include=players', async (t) => {
t.truthy(playerIds.includes(player2.id));
});
test('multiple includes /team?include=players&include=city', async (t) => {
test('belongsTo with alias /player?include={"model": "Master", "as": "Coach"}', async(t) => {
const { server, instances } = t.context;
const { team1, master1 } = instances;
const path = `/player/${team1.id}?include={"model": "Master", "as": "Coach"}`;
const { result, statusCode } = await server.inject(path);
t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id);
t.is(result.Coach.id, master1.id);
});
test('multiple includes /team?include=players&include=city', async(t) => {
const { server, instances } = t.context;
const { team1, player1, player2, city1 } = instances;
const path = `/team/${team1.id}?include=players&include=city`;
@ -71,7 +82,7 @@ test('multiple includes /team?include=players&include=city', async (t) => {
t.is(result.City.id, city1.id);
});
test('multiple includes /team?include[]=players&include[]=city', async (t) => {
test('multiple includes /team?include[]=players&include[]=city', async(t) => {
const { server, instances } = t.context;
const { team1, player1, player2, city1 } = instances;
const path = `/team/${team1.id}?include[]=players&include[]=city`;
@ -86,8 +97,23 @@ test('multiple includes /team?include[]=players&include[]=city', async (t) => {
t.is(result.City.id, city1.id);
});
test('multiple includes /team?include[]=players&include[]={"model": "City"}', async(t) => {
const { server, instances } = t.context;
const { team1, player1, player2, city1 } = instances;
const path = `/team/${team1.id}?include[]=players&include[]={"model": "City"}`;
const { result, statusCode } = await server.inject(path);
t.is(statusCode, STATUS_OK);
t.is(result.id, team1.id);
const playerIds = result.Players.map(({ id }) => id);
t.truthy(playerIds.includes(player1.id));
t.truthy(playerIds.includes(player2.id));
t.is(result.City.id, city1.id);
});
test('inlcude filter /teams?include[]={"model": "City", "where": {"name": "Healdsburg"}}'
, async (t) => {
, async(t) => {
const { server } = t.context;
const url = '/teams?include[]={"model": "City", "where": {"name": "Healdsburg"}}';
const method = 'GET';
@ -95,3 +121,37 @@ test('inlcude filter /teams?include[]={"model": "City", "where": {"name": "Heald
const { statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
});
test('nested inlcude filter ' +
'/city?include[]={"model": "Team", "include": {"model": "Player", "where": {"name": "Pinot"}}}'
, async(t) => {
const { instances, server } = t.context;
const { city1, team1, player2 } = instances;
// eslint-disable-next-line max-len
const url = '/city?include[]={"model": "Team", "include": {"model": "Player", "where": {"name": "Pinot"}}}';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.id, city1.id);
t.is(result.Teams[0].id, team1.id);
t.is(result.Teams[0].Players[0].id, player2.id);
});
test('nested inlcude filter ' +
'/city?include[]={"model": "Team", "include": {"model": "City", "where": {"name": "Healdsburg"}}}'
, async(t) => {
const { instances, server } = t.context;
const { city1, team1, team2 } = instances;
// eslint-disable-next-line max-len
const url = '/city?include[]={"model": "Team", "include": {"model": "City", "where": {"name": "Healdsburg"}}}';
const method = 'GET';
const { result, statusCode } = await server.inject({ url, method });
t.is(statusCode, STATUS_OK);
t.is(result.id, city1.id);
const teamIds = result.Teams.map(({ id }) => id);
t.truthy(teamIds.includes(team1.id));
t.truthy(teamIds.includes(team2.id));
});

View File

@ -56,6 +56,7 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
const modelName = model._singular;
const modelAttributes = Object.keys(model.attributes);
const associatedModelNames = Object.keys(model.associations);
const associatedModelAliases = _.map(model.associations, (assoc => assoc.as));
const modelAssociations = [
...associatedModelNames,
..._.flatMap(associatedModelNames, (associationName) => {
@ -82,12 +83,13 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
...attributeValidation,
...sequelizeOperators,
}),
as: joi.string().valid(...associatedModelAliases),
include: joi.any(), // @Todo: should validate the same as associationValidation var below
})
: joi.valid(null);
const associationValidation = {
include: [
joi.array().items(validAssociationsString),
joi.array().items(validAssociationsObject),
joi.array().items(validAssociationsString, validAssociationsObject),
validAssociationsString,
validAssociationsObject,
],
@ -161,14 +163,14 @@ export default (server, model, { prefix, defaultConfig: config, models: permissi
}
};
export const list = ({ server, model, prefix = '/', config }) => {
export const list = async ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: path.join(prefix, model._plural),
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const { limit, offset } = parseLimitAndOffset(request);
const order = parseOrder(request);
@ -188,14 +190,14 @@ export const list = ({ server, model, prefix = '/', config }) => {
});
};
export const get = ({ server, model, prefix = '/', config }) => {
export const get = async ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: path.join(prefix, model._singular, '{id?}'),
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const { id } = request.params;
if (id) where[model.primaryKeyField] = id;
@ -212,14 +214,14 @@ export const get = ({ server, model, prefix = '/', config }) => {
});
};
export const scope = ({ server, model, prefix = '/', config }) => {
export const scope = async ({ server, model, prefix = '/', config }) => {
server.route({
method: 'GET',
path: path.join(prefix, model._plural, '{scope}'),
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
const { limit, offset } = parseLimitAndOffset(request);
const order = parseOrder(request);
@ -313,14 +315,14 @@ export const destroyAll = ({ server, model, prefix = '/', config }) => {
});
};
export const destroyScope = ({ server, model, prefix = '/', config }) => {
export const destroyScope = async ({ server, model, prefix = '/', config }) => {
server.route({
method: 'DELETE',
path: path.join(prefix, model._plural, '{scope}'),
@error
async handler(request, reply) {
const include = parseInclude(request);
const include = await parseInclude(request);
const where = parseWhere(request);
if (include instanceof Error) return void reply(include);

View File

@ -1,6 +1,7 @@
import { omit, identity, toNumber, isString, isUndefined } from 'lodash';
import { notImplemented } from 'boom';
import joi from 'joi';
import Promise from 'bluebird';
const sequelizeKeys = ['include', 'order', 'limit', 'offset'];
@ -17,7 +18,9 @@ const getModels = (request) => {
return models;
};
export const parseInclude = request => {
export const parseInclude = async(request) => {
if (typeof request.query.include === 'undefined') return [];
const include = Array.isArray(request.query.include)
? request.query.include
: [request.query.include]
@ -26,32 +29,58 @@ export const parseInclude = request => {
const models = getModels(request);
if (models.isBoom) return models;
return include.map(b => {
const getModelInstance = includeItem => {
return new Promise(async(resolve) => {
if (includeItem) {
if (typeof includeItem !== 'object') {
const singluarOrPluralMatch = Object.keys(models).find((modelName) => {
const { _singular, _plural } = models[modelName];
return _singular === includeItem || _plural === includeItem;
});
if (singluarOrPluralMatch) {
return resolve(models[singluarOrPluralMatch]);
}
}
if (typeof includeItem === 'string' && models.hasOwnProperty(includeItem)) {
return resolve(models[includeItem]);
} else if (typeof includeItem === 'object') {
if (
typeof includeItem.model === 'string' &&
includeItem.model.length &&
models.hasOwnProperty(includeItem.model)
) {
includeItem.model = models[includeItem.model];
}
if (includeItem.hasOwnProperty('include')) {
includeItem.include = await getModelInstance(includeItem.include);
return resolve(includeItem);
} else {
return resolve(includeItem);
}
}
} else {
return resolve(includeItem);
}
});
};
const jsonValidation = joi.string().regex(/^\{.*?"model":.*?\}$/);
const includes = include.map(async(b) => {
let a = b;
try {
if (joi.string().regex(/^\{.*?"model":.*?\}$/)) {
if (!jsonValidation.validate(a).error) {
a = JSON.parse(b);
}
} catch (e) {
//
}
if (typeof a !== 'object') {
const singluarOrPluralMatch = Object.keys(models).find((modelName) => {
const { _singular, _plural } = models[modelName];
return _singular === a || _plural === a;
});
if (singluarOrPluralMatch) return models[singluarOrPluralMatch];
}
if (typeof a === 'string') return models[a];
if (a && typeof a.model === 'string' && a.model.length) {
a.model = models[a.model];
}
return a;
return getModelInstance(a);
}).filter(identity);
return await Promise.all(includes);
};
export const parseWhere = request => {

18
test/fixtures/models/master.js vendored Normal file
View File

@ -0,0 +1,18 @@
export default (sequelize, DataTypes) => {
return sequelize.define('Master', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: DataTypes.STRING,
}, {
classMethods: {
associate: (models) => {
models.Master.hasMany(models.Player, {
foreignKey: 'coachId'
});
},
},
});
};

View File

@ -14,6 +14,10 @@ export default (sequelize, DataTypes) => {
models.Player.belongsTo(models.Team, {
foreignKey: { name: 'teamId' },
});
models.Player.belongsTo(models.Master, {
foreignKey: 'coachId',
as: 'Coach',
});
},
},
scopes: {

View File

@ -14,15 +14,16 @@ const modelNames = [
{ Singluar: 'City', singular: 'city', Plural: 'Cities', plural: 'cities' },
{ Singluar: 'Team', singular: 'team', Plural: 'Teams', plural: 'teams' },
{ Singluar: 'Player', singular: 'player', Plural: 'Players', plural: 'players' },
{ Singluar: 'Master', singular: 'master', Plural: 'Masters', plural: 'masters' },
];
export default (test) => {
test.beforeEach('get an open port', async (t) => {
test.beforeEach('get an open port', async(t) => {
t.context.port = await getPort();
});
test.beforeEach('setup server', async (t) => {
test.beforeEach('setup server', async(t) => {
const sequelize = t.context.sequelize = new Sequelize({
dialect: 'sqlite',
logging: false,
@ -54,17 +55,25 @@ export default (test) => {
);
});
test.beforeEach('create data', async (t) => {
const { Player, Team, City } = t.context.sequelize.models;
test.beforeEach('create data', async(t) => {
const { Player, Master, Team, City } = t.context.sequelize.models;
const city1 = await City.create({ name: 'Healdsburg' });
const team1 = await Team.create({ name: 'Baseballs', cityId: city1.id });
const team2 = await Team.create({ name: 'Footballs', cityId: city1.id });
const master1 = await Master.create({ name: 'Shifu' });
const master2 = await Master.create({ name: 'Oogway' });
const player1 = await Player.create({
name: 'Cat', teamId: team1.id, active: true,
name: 'Cat', teamId: team1.id, active: true, coachId: master1.id
});
const player2 = await Player.create({ name: 'Pinot', teamId: team1.id });
const player3 = await Player.create({ name: 'Syrah', teamId: team2.id });
t.context.instances = { city1, team1, team2, player1, player2, player3 };
const player2 = await Player.create({
name: 'Pinot', teamId: team1.id, coachId: master1.id
});
const player3 = await Player.create({
name: 'Syrah', teamId: team2.id, coachId: master2.id
});
t.context.instances = {
city1, team1, team2, player1, player2, player3, master1, master2
};
});
// kill the server so that we can exit and don't leak memory