koa-restql
Advanced tools
Comparing version 0.0.1 to 0.1.0
'use strict' | ||
const qs = require('qs'); | ||
const debug = require('debug')('koa-restql:common'); | ||
const defaultLimit = 20; | ||
function switchByType (param, callbacks) { | ||
module.exports.getAssociationName = (association) => { | ||
let isSingular = association.isSingleAssociation | ||
, name = association.options.name; | ||
callbacks = callbacks || {}; | ||
return isSingular ? name.singular : name.plural; | ||
} | ||
const { | ||
object, array, string, bool, number, defaults | ||
} = callbacks; | ||
const parseAttributes = (_attributes) => { | ||
let attrs; | ||
let callback; | ||
if (Array.isArray(_attributes)) { | ||
attrs = _attributes; | ||
} else if (typeof _attributes === 'string') { | ||
attrs = _attributes.split(/(,|\ )/); | ||
} | ||
switch (typeof param) { | ||
case 'object': | ||
if (Array.isArray(param)) { | ||
callback = array; | ||
} else { | ||
callback = object; | ||
} | ||
break; | ||
case 'string': | ||
callback = string; | ||
break; | ||
case 'boolean': | ||
callback = bool; | ||
break; | ||
case 'number': | ||
callback = number; | ||
break; | ||
default: | ||
callback = defaults; | ||
break; | ||
} | ||
return attrs; | ||
if (callback !== undefined) { | ||
if ('function' === typeof callback) { | ||
return callback(param); | ||
} else { | ||
return callback; | ||
} | ||
} | ||
} | ||
const unionAttributes = (_attributes, attributes) => { | ||
function shouldIgnoreAssociation (method, options) { | ||
if (typeof _attributes === 'object') { | ||
if (_attributes.include) { | ||
/** | ||
* @FIXME | ||
* | ||
* This is a bug in Sequelize | ||
* include doesn't work at all | ||
*/ | ||
_attributes.include = parseAttributes(_attributes.include); | ||
} | ||
if (_attributes.exclude) { | ||
_attributes.exclude = parseAttributes(_attributes.exclude); | ||
} | ||
} else { | ||
return parseAttributes(_attributes); | ||
} | ||
options = options || {} | ||
return _attributes; | ||
let ignore = options.ignore; | ||
return switchByType(ignore, { | ||
array : () => | ||
ignore.find(ignoreMethod => ignoreMethod.toLowerCase() === method), | ||
bool : () => ignore | ||
}) | ||
} | ||
const unionWhere = (_where, attributes) => { | ||
if (!_where || typeof _where !== 'object') | ||
return; | ||
function parseAttributes (_attributes, attributes) { | ||
let attrs; | ||
let where; | ||
if (_attributes) | ||
Object.keys(_where).forEach(key => { | ||
if (attributes[key]) { | ||
where = where || {}; | ||
where[key] = _where[key]; | ||
} | ||
return switchByType(_attributes, { | ||
array : () => (_attributes.filter(attr => attributes[attr])), | ||
string : () => (_attributes.split(/,/).filter(attr => attributes[attr])) | ||
}) | ||
return where; | ||
} | ||
const unionOrder = (_order, attributes) => { | ||
if (!_order) | ||
return; | ||
function unionWhere (_where) { | ||
if (Array.isArray(_order)) { | ||
if (!_order.length) | ||
return; | ||
return switchByType(_where, { | ||
object : () => { | ||
let where; | ||
return _order.filter(item => { | ||
if (!item) | ||
return false; | ||
let name; | ||
if (Array.isArray(item)) { | ||
name = item[0]; | ||
} else if (typeof item === 'string'){ | ||
name = item.split(' ')[0]; | ||
} | ||
return name && !!attributes[name]; | ||
}).map(item => { | ||
if (typeof item === 'string') { | ||
return item.split(' '); | ||
} | ||
return item; | ||
}) | ||
} else if (typeof _order === 'string') { | ||
let order = _order.split(' '); | ||
if (attributes[order[0]]) { | ||
return [order]; | ||
} | ||
} | ||
Object.keys(_where).forEach(key => { | ||
if (!/^_/.test(key)) { | ||
where = where || {}; | ||
where[key] = _where[key]; | ||
} | ||
}) | ||
return where; | ||
} | ||
}) | ||
} | ||
const shouldIgnoreAssociation = (method, options) => { | ||
function parseInclude (_include, associations, method) { | ||
options = options || {} | ||
return switchByType(_include, { | ||
string : () => { | ||
let ignore = options.ignore; | ||
let association = associations[_include]; | ||
switch (typeof ignore) { | ||
case 'object': | ||
if (Array.isArray(ignore)) { | ||
if (ignore.indexOf(method.name) !== -1) | ||
return true; | ||
} | ||
break; | ||
case 'boolean': | ||
if (ignore) return true; | ||
break; | ||
default: | ||
break; | ||
} | ||
if (!association) | ||
return; | ||
return false; | ||
} | ||
if (shouldIgnoreAssociation(method, association.options.restql)) | ||
return; | ||
const parseInclude = (_include, associations, method) => { | ||
return association; | ||
}, | ||
let include, association, where, attributes, through, required; | ||
object : () => { | ||
if ('string' === typeof _include) { | ||
let include = [] | ||
, where = _include.where | ||
, attributes = _include.attributes | ||
, required = !!+_include.required | ||
, through = _include.through | ||
, association = associations[_include.association]; | ||
association = associations[_include]; | ||
if (!association) | ||
return; | ||
if (!association) | ||
return; | ||
if (shouldIgnoreAssociation(method, association.options.restql)) | ||
return; | ||
let options = association.options; | ||
} else if ('object' === typeof _include) { | ||
if (shouldIgnoreAssociation(method,options.restql)) | ||
return; | ||
where = _include.where; | ||
attributes = _include.attributes; | ||
required = _include.required; | ||
through = _include.through; | ||
if (_include.include) { | ||
include = unionInclude(_include.include, association.target.associations); | ||
} | ||
association = associations[_include.association]; | ||
return { | ||
where, attributes, through, association, required, include | ||
} | ||
if (!association) | ||
return; | ||
} | ||
let options = association.options | ||
if (shouldIgnoreAssociation(method, association.options.restql)) | ||
return; | ||
}) | ||
if (_include.include) { | ||
include = unionInclude(_include.include, association.target.associations); | ||
} | ||
} | ||
return { | ||
where, attributes, through, association, required, | ||
include: include || [] | ||
} | ||
} | ||
const unionInclude = (_include, associations, method) => { | ||
if (!_include) | ||
return; | ||
function unionInclude (_include, associations, method) { | ||
if (Array.isArray(_include)) { | ||
return _include.map(item => { | ||
return parseInclude(item, associations, method); | ||
}).filter(item => item); | ||
} else { | ||
let include = parseInclude(_include, associations, method); | ||
return include ? [ include ] : []; | ||
} | ||
return switchByType(_include, { | ||
array: () => { | ||
return _include | ||
.map(item => parseInclude(item, associations, method)) | ||
.filter(item => item); | ||
}, | ||
object: () => { | ||
let include = parseInclude(_include, associations, method); | ||
return include || []; | ||
}, | ||
string: () => { | ||
let include = parseInclude(_include, associations, method); | ||
return include || []; | ||
}, | ||
defaults: () => ([]) | ||
}) | ||
} | ||
module.exports.parseQuerystring = (querystring, model, method) => { | ||
function parseQuery (query, model, method, options) { | ||
let attributes = model.attributes | ||
, query = qs.parse(querystring, { allowDots: true }); | ||
const { | ||
_attributes, _order, _through, _include, _offset, _limit | ||
} = query | ||
debug(query); | ||
let where = unionWhere(query) | ||
, include = unionInclude(_include, model.associations, method) | ||
, attributes = _attributes | ||
, order = _order | ||
, through = _through | ||
, offset = +_offset || 0 | ||
, limit = +_limit || options.query._limit | ||
let _where = unionWhere(query, attributes) | ||
, _attributes = unionAttributes(query._attributes, attributes) | ||
, _order = unionOrder(query._order, attributes) | ||
, _through = query._through | ||
, _include = unionInclude(query._include, model.associations, method) | ||
, _offset = query._offset ? +query._offset : 0 | ||
, _limit = query._limit ? +query._limit : defaultLimit | ||
, _ignoreDuplicates = query._ignoreDuplicates; | ||
debug(where) | ||
debug(attributes) | ||
debug(order) | ||
debug(through) | ||
debug(include) | ||
debug(offset) | ||
debug(limit) | ||
debug(_where); | ||
debug(_attributes); | ||
debug(_order); | ||
debug(_through); | ||
debug(_include); | ||
debug(_offset); | ||
debug(_limit); | ||
debug(_ignoreDuplicates); | ||
return { | ||
offset, limit, order, where, through, include, attributes | ||
} | ||
return { | ||
_offset : _offset, | ||
_limit : _limit, | ||
_order : _order, | ||
_where : _where, | ||
_through : _through, | ||
_include : _include || [], | ||
_attributes : _attributes || Object.keys(model.attributes), | ||
_ignoreDuplicates : _ignoreDuplicates | ||
}; | ||
} | ||
module.exports.shouldIgnoreAssociation = shouldIgnoreAssociation; | ||
module.exports.parseQuery = parseQuery | ||
module.exports.switchByType = switchByType | ||
module.exports.shouldIgnoreAssociation = shouldIgnoreAssociation |
@@ -5,18 +5,4 @@ 'use strict' | ||
/** | ||
* when isSingular === undefined | ||
* method can be mounted on either singular or plural path | ||
* | ||
* when isSingular === true | ||
* method can just be mounted on a singular path | ||
* | ||
* when isSingular === false | ||
* method can just be mounted on a plural path | ||
*/ | ||
module.exports = [ | ||
{ name : 'get' }, | ||
{ name : 'post', isSingular : false }, | ||
{ name : 'put' , isSingular : true }, | ||
{ name : 'del' , isSingular : true } | ||
] | ||
'get', 'post', 'put', 'del' | ||
]; |
'use strict' | ||
const co = require('co'); | ||
const parse = require('co-body'); | ||
const debug = require('debug')('koa-restql:middlewares'); | ||
const co = require('co') | ||
const qs = require('qs') | ||
const parse = require('co-body') | ||
const debug = require('debug')('koa-restql:middlewares') | ||
const common = require('./common'); | ||
const getAssociationName = common.getAssociationName; | ||
const parseQuerystring = common.parseQuerystring; | ||
const common = require('./common') | ||
const capitalizeFirstLetter = (string) => { | ||
return string.charAt(0).toUpperCase() + string.slice(1); | ||
const switchByType = common.switchByType | ||
function _getIndexes (model) { | ||
const { | ||
primaryKeys, options: { indexes, uniqueKeys } | ||
} = model | ||
const idxes = [] | ||
if (primaryKeys) { | ||
const keys = Object.keys(primaryKeys) | ||
if (keys.length) { | ||
idxes.push({ | ||
name : 'PRIMARY', | ||
unique : true, | ||
primary : true, | ||
fields : keys | ||
}) | ||
} | ||
} | ||
indexes.forEach(index => { | ||
idxes.push({ | ||
unique : index.unique, | ||
name : index.name, | ||
fields : index.fields | ||
}) | ||
}) | ||
Object.keys(uniqueKeys).forEach(key => { | ||
let uniqueKey = uniqueKeys[key] | ||
idxes.push({ | ||
unique : true, | ||
name : uniqueKey.name, | ||
fields : uniqueKey.fields | ||
}) | ||
}) | ||
return idxes | ||
} | ||
const get = (method, model, association) => { | ||
return function * (next) { | ||
let params = this.params | ||
, id = params.id | ||
, qs = this.request.querystring | ||
, query, data; | ||
function _getUniqueIndexes (model) { | ||
return _getIndexes(model).filter(index => index.unique) | ||
let debugInfo = `get ${this.request.url}, using model: ${model.name}`; | ||
} | ||
if (association) { | ||
debugInfo += `, with association: | ||
${common.getAssociationName(association)}`; | ||
function _getInstanceValidIndexes (indexes, data) { | ||
if (!data) | ||
return [] | ||
return indexes.filter(index => | ||
index.fields.every(field => data[field] !== undefined)) | ||
} | ||
function _getInstanceValidIndexFields (indexes, data) { | ||
if (!data || !indexes) | ||
return | ||
const validIndexes = _getInstanceValidIndexes(indexes, data) | ||
if (!validIndexes.length) | ||
return | ||
const index = validIndexes[0] | ||
const fields = {} | ||
index.fields.forEach(field => { | ||
fields[field] = data[field] | ||
}) | ||
return fields | ||
} | ||
function * _upsert (model, data) { | ||
const uniqueIndexes = _getUniqueIndexes(model) | ||
const where = _getInstanceValidIndexFields(uniqueIndexes, data) | ||
if (!where) { | ||
this.throw('RestQL: unique index fields cannot be found', 400) | ||
} | ||
let created | ||
try { | ||
created = | ||
yield model.upsert(data) | ||
} catch (error) { | ||
if (error.name !== 'SequelizeUniqueConstraintError') { | ||
throw new Error(error) | ||
} | ||
const message = `RestQL: ${model.name} unique constraint error` | ||
this.throw(message, 409) | ||
debug(debugInfo); | ||
} | ||
if (!association) { | ||
query = parseQuerystring(qs, model, method); | ||
data = | ||
yield model.find({ | ||
where, | ||
paranoid: false | ||
}) | ||
if (id === undefined) { | ||
data = yield model.findAndCount({ | ||
attributes : query._attributes, | ||
where : query._where, | ||
order : query._order, | ||
include : query._include, | ||
limit : query._limit, | ||
offset : query._offset | ||
}) | ||
yield data.restore() | ||
this.response.set('X-Range', | ||
`objects ${query._offset}-${query._offset + data.rows.length}/${data.count}`); | ||
data = data.rows; | ||
} else { | ||
data = yield model.findOne({ | ||
attributes : query._attributes, | ||
where : { id }, | ||
include : query._include | ||
}) | ||
return { created, data } | ||
if (!data) { | ||
this.throw(`${model.name} ${id} is not found`, 404); | ||
} | ||
} | ||
} else { | ||
let associationId = params.associationId | ||
, associationModel = association.target | ||
, order; | ||
} | ||
query = parseQuerystring(qs, associationModel, method); | ||
function * _bulkUpsert (model, data) { | ||
if (!associationId) { | ||
let name = getAssociationName(association) | ||
, isPlural = association.isMultiAssociation | ||
, getter = `get${capitalizeFirstLetter(name)}` | ||
, counter = `count${capitalizeFirstLetter(name)}`; | ||
if (!data.length) | ||
return [] | ||
data = yield model.findOne({ | ||
where : { id } | ||
}) | ||
/** | ||
* updateOnDuplicate fields should be consistent | ||
*/ | ||
let isValid = true | ||
if (data.length) { | ||
let match = JSON.stringify(Object.keys(data[0]).sort()) | ||
isValid = data.map(row => | ||
JSON.stringify(Object.keys(row).sort())).every(item => item === match) | ||
} | ||
if (!data) { | ||
this.throw(`${model.name} ${id} is not found`, 404); | ||
} | ||
if (!isValid) { | ||
this.throw('RestQL: array elements have different attributes', 400) | ||
} | ||
let promises = []; | ||
const $or = [] | ||
const uniqueIndexes = _getUniqueIndexes(model) | ||
promises.push(data[getter]({ | ||
attributes : query._attributes, | ||
where : query._where, | ||
order : query._order, | ||
include : query._include, | ||
through : query._through, | ||
limit : query._limit, | ||
offset : query._offset | ||
})); | ||
data.forEach(row => { | ||
if (isPlural) { | ||
promises.push(data[counter]({ | ||
where: query._where | ||
})) | ||
} | ||
const where = _getInstanceValidIndexFields(uniqueIndexes, row) | ||
data = yield promises; | ||
if (!where) { | ||
this.throw('RestQL: unique index fields cannot be found', 400) | ||
} | ||
if (isPlural) { | ||
this.response.set('X-Range', | ||
`objects ${query._offset}-${query._offset + data[0].length}/${data[1]}`); | ||
} | ||
$or.push(where) | ||
}) | ||
data = data[0]; | ||
} else { | ||
data = yield associationModel.findOne({ | ||
attributes : query._attributes, | ||
where : { id: associationId }, | ||
include : query._include | ||
}) | ||
/** | ||
* ignoreDuplicates only work in mysql | ||
*/ | ||
if (!data) { | ||
this.throw(`${model.name} ${id} | ||
association ${associationId} is not found`, 404); | ||
} | ||
} | ||
try { | ||
let updatedFields = Object.keys(data[0]).filter(key => | ||
['id'].indexOf(key) === -1) | ||
yield model.bulkCreate(data, { | ||
updateOnDuplicate: updatedFields | ||
}) | ||
} catch (error) { | ||
if (error.name !== 'SequelizeUniqueConstraintError') { | ||
throw new Error(error) | ||
} | ||
const message = `RestQL: ${model.name} unique constraint error` | ||
this.throw(message, 409) | ||
this.body = data; | ||
this.status = 200; | ||
} | ||
yield next; | ||
data = | ||
yield model.findAll({ | ||
where: { $or }, | ||
order: [['id', 'ASC']] | ||
}) | ||
return data | ||
} | ||
function _getUniqueConstraintErrorFields (model, error) { | ||
const attributes = model.attributes | ||
const fields = error.fields | ||
if (!fields) | ||
return | ||
let fieldsIsValid = Object.keys(fields).every(key => attributes[key]) | ||
if (fieldsIsValid) | ||
return fields | ||
const uniqueIndexes = _getUniqueIndexes(model) | ||
const uniqueIndex = uniqueIndexes.find(index => fields[index.name]) | ||
if (uniqueIndex && Array.isArray(uniqueIndex.fields)) { | ||
let value = fields[uniqueIndex.name] | ||
value = common.switchByType(value, { | ||
'number' : value => [ value ], | ||
'string' : value => value.split('-') | ||
}) | ||
if (!value || !value.length) | ||
return | ||
const ret = {} | ||
uniqueIndex.fields.forEach((field, index) => { | ||
ret[field] = value[index] | ||
}) | ||
return ret | ||
} | ||
} | ||
const isSameDeletedAt = (a, b) => { | ||
if (a instanceof Date && b instanceof Date) { | ||
return a.getTime() === b.getTime(); | ||
} else { | ||
return a === b; | ||
function * _handleUniqueConstraintError (model, error) { | ||
const message = `RestQL: ${model.name} unique constraint error` | ||
const status = 409 | ||
const fields = _getUniqueConstraintErrorFields(model, error) | ||
const attributes = model.attributes | ||
const paranoid = model.options.paranoid | ||
const deletedAtCol = model.options.deletedAt | ||
if (!deletedAtCol || !paranoid) | ||
this.throw(message, status) | ||
let row = | ||
yield model.find({ | ||
paranoid: false, | ||
where: fields | ||
}) | ||
if (!fields || !row) { | ||
this.throw('RestQL: Sequelize goes wrong', 500) | ||
} | ||
let deletedAtVal = attributes[deletedAtCol].defaultValue | ||
if (deletedAtVal === undefined) { | ||
deletedAtVal = null | ||
} | ||
function isSameDeletedAt (a, b) { | ||
if (a instanceof Date && b instanceof Date) { | ||
return a.getTime() === b.getTime() | ||
} else { | ||
return a === b | ||
} | ||
} | ||
if (isSameDeletedAt(row[deletedAtCol], deletedAtVal)) { | ||
this.throw(message, status) | ||
} | ||
for (let key in attributes) { | ||
let defaultValue = attributes[key].defaultValue | ||
if (defaultValue !== undefined) { | ||
row.setDataValue(key, defaultValue) | ||
} | ||
} | ||
return { row, fields } | ||
} | ||
const getUniqueConstraintErrorFields = (model, e) => { | ||
function * _create (model, data, options) { | ||
let indexes = model.options.indexes | ||
, attributes = model.attributes | ||
, fields = {} | ||
, uniqueIndex, fieldsIsValid; | ||
const id = data.id | ||
if (!e.fields) | ||
return; | ||
try { | ||
fieldsIsValid = Object.keys(e.fields).every(key => attributes[e.fields[key]]); | ||
if (id) { | ||
delete data.id | ||
} | ||
if (fieldsIsValid) | ||
return e.fields; | ||
data = | ||
yield model.create(data, options) | ||
let index = indexes.find(index => e.fields[index.name] && index.unique); | ||
return data | ||
if (index && Array.isArray(index.fields)) { | ||
let value = e.fields[index.name]; | ||
} catch (error) { | ||
switch (typeof value) { | ||
case 'number': | ||
value = [ value ]; | ||
case 'string': | ||
value = value.split('-'); | ||
break; | ||
if (error.name !== 'SequelizeUniqueConstraintError') { | ||
throw new Error(error) | ||
} | ||
index.fields.forEach((field, index) => { | ||
fields[field] = value[index] | ||
}) | ||
const conflict = | ||
yield* _handleUniqueConstraintError.call(this, model, error) | ||
const { | ||
row, fields | ||
} = conflict | ||
data = | ||
yield* _update.call(this, model, | ||
Object.assign({}, row.dataValues, data), { where: fields }) | ||
data = data[0] | ||
return data | ||
} | ||
return fields; | ||
} | ||
const create = (model, row, options) => { | ||
return function * () { | ||
let id = row.id | ||
, message = `${model.name} unique constraint error` | ||
, status = 409 | ||
, data; | ||
function * _bulkCreate (model, data) { | ||
options = options || {} | ||
const $or = [] | ||
const conflicts = [] | ||
const uniqueIndexes = _getUniqueIndexes(model) | ||
let { | ||
ignoreDuplicates = false | ||
} = options; | ||
data = data.slice() | ||
data.forEach(row => { | ||
const where = _getInstanceValidIndexFields(uniqueIndexes, row) | ||
if (!where) { | ||
this.throw('RestQL: unique index fields cannot be found', 400) | ||
} | ||
$or.push(where) | ||
}) | ||
while (true) { | ||
try { | ||
if (id) { | ||
delete row.id; | ||
yield model.bulkCreate(data) | ||
break | ||
} catch (error) { | ||
if (error.name !== 'SequelizeUniqueConstraintError') { | ||
throw new Error(error) | ||
} | ||
data = | ||
yield model.create(row, options); | ||
const conflict = | ||
yield* _handleUniqueConstraintError.call(this, model, error) | ||
} catch (e) { | ||
if (e.name === 'SequelizeUniqueConstraintError') { | ||
const { | ||
row, fields | ||
} = conflict | ||
let where = getUniqueConstraintErrorFields(model, e) | ||
, attributes = model.attributes | ||
, deletedAtCol = model.options.deletedAt; | ||
const index = data.findIndex(row => | ||
Object.keys(fields).every(key => fields[key] == row[key])) | ||
if (!deletedAtCol) | ||
this.throw(message, status); | ||
if (index !== -1) { | ||
conflict.row = Object.assign({}, row.dataValues, data[index]) | ||
conflicts.push(conflict) | ||
data.splice(index, 1) | ||
} else { | ||
this.throw('RestQL: bulkCreate unique index field error', 500) | ||
} | ||
data = yield model.find({ | ||
paranoid: false, | ||
where | ||
}) | ||
} | ||
if (!data) { | ||
this.throw('Sequelize goes wrong', 500); | ||
} | ||
} | ||
let deletedAtVal = attributes[deletedAtCol].defaultValue; | ||
if (deletedAtVal === undefined) { | ||
deletedAtVal = null; | ||
} | ||
if (conflicts.length) { | ||
const rows = conflicts.map(conflicts => conflicts.row) | ||
const upsert = (row) => { | ||
return function * () { | ||
yield model.upsert(row); | ||
let data = yield model.find({ | ||
where | ||
}); | ||
return data; | ||
} | ||
} | ||
try { | ||
if (!isSameDeletedAt(data[deletedAtCol], deletedAtVal)) { | ||
yield data.restore(); | ||
data = yield upsert(row); | ||
} else { | ||
if (!ignoreDuplicates) { | ||
this.throw(message, status); | ||
} | ||
data = yield upsert(row); | ||
} | ||
} else { | ||
throw new Error(e); | ||
yield model.bulkCreate(rows, { | ||
updateOnDuplicate: Object.keys(model.attributes) | ||
}) | ||
} catch (error) { | ||
if (error.name !== 'SequelizeUniqueConstraintError') { | ||
throw new Error(error) | ||
} | ||
const message = `RestQL: ${model.name} unique constraint error` | ||
this.throw(message, 409) | ||
} | ||
} | ||
return data; | ||
} | ||
data = | ||
yield model.findAll({ | ||
where: { $or }, | ||
order: [['id', 'ASC']] | ||
}) | ||
return data | ||
} | ||
const post = (method, model, association) => { | ||
return function * (next) { | ||
let body = this.request.body || (yield parse(this)) | ||
, params = this.params | ||
, qs = this.request.querystring | ||
, data; | ||
function * _update (model, data, options) { | ||
let debugInfo = `post ${this.request.url}, using model: ${model.name}`; | ||
try { | ||
if (association) { | ||
debugInfo += `, with association: | ||
${common.getAssociationName(association)}`; | ||
if (data.id) { | ||
delete data.id | ||
} | ||
debug(debugInfo); | ||
data = | ||
yield model.update(data, options) | ||
const _create = (model) => { | ||
return function * () { | ||
let query = parseQuerystring(qs, model, method); | ||
if (Array.isArray(body)) { | ||
let promises = body.map(row => { | ||
let gen = create(model, row, { | ||
include : query._include, | ||
ignoreDuplicates : query._ignoreDuplicates | ||
}) | ||
return co(gen); | ||
}) | ||
data = | ||
yield model.findAll(options) | ||
let data = yield promises; | ||
return data; | ||
} else { | ||
return yield create(model, body, { | ||
include : query._include, | ||
ignoreDuplicates : query._ignoreDuplicates | ||
}); | ||
} | ||
} | ||
return data | ||
} catch (error) { | ||
if (error.name !== 'SequelizeUniqueConstraintError') { | ||
throw new Error(error) | ||
} | ||
if (!association) { | ||
data = yield _create(model); | ||
} else { | ||
let id = params.id | ||
, name = association.options.name.singular | ||
, add = `add${capitalizeFirstLetter(name)}`; | ||
const conflict = | ||
yield* _handleUniqueConstraintError.call(this, model, error) | ||
data = yield model.findOne({ | ||
where: { id } | ||
}); | ||
const { row } = conflict | ||
if (!data) { | ||
this.throw(`${model.name} ${id} is not found`, 404); | ||
/** | ||
* @FIXME | ||
* restql should delete the conflict with paranoid = false | ||
* and update again, now return 409 directly | ||
* for conflict happens rarely | ||
*/ | ||
const message = `RestQL: ${model.name} unique constraint error` | ||
this.throw(message, 409) | ||
} | ||
} | ||
function * _findExistingRows (model, data) { | ||
const $or = [] | ||
const uniqueIndexes = _getUniqueIndexes(model) | ||
function getOr (uniqueIndexes, data) { | ||
let fields = _getInstanceValidIndexFields(uniqueIndexes, data) | ||
, row = data | ||
return { fields, row } | ||
} | ||
common.switchByType(data, { | ||
object : (data) => $or.push(getOr(uniqueIndexes, data)), | ||
array : (data) => data.forEach(row => $or.push(getOr(uniqueIndexes, row))) | ||
}) | ||
data = | ||
yield model.findAll({ | ||
where: { $or : $or.map(or => or.fields) } | ||
}) | ||
let existingRows = [] | ||
let newRows = [] | ||
if (data.length === $or.length) { | ||
existingRows = data | ||
} else { | ||
/* | ||
* find existing rows | ||
*/ | ||
$or.forEach(or => { | ||
let index = data.findIndex(row => | ||
Object.keys(or.fields).every(key => row[key] === or.row[key])) | ||
if (index !== -1) { | ||
existingRows.push(data[index]) | ||
data.splice(index, 1) | ||
} else { | ||
newRows.push(or.row) | ||
} | ||
let associationData = yield _create(association.target); | ||
yield data[add](associationData); | ||
}) | ||
data = yield association.target.findById(associationData.id); | ||
} | ||
return { existingRows, newRows } | ||
} | ||
function before () { | ||
return function * (next) { | ||
debug(`RestQL: ${this.request.method} ${this.url}`) | ||
this.restql = this.restql || {} | ||
this.restql.params = this.restql.params || {} | ||
this.restql.request = this.restql.request || {} | ||
this.restql.response = this.restql.response || {} | ||
yield* next | ||
} | ||
} | ||
function after () { | ||
return function * (next) { | ||
const { | ||
response | ||
} = this.restql | ||
this.response.status = response.status || 200 | ||
this.response.body = response.body | ||
const headers = response.headers || {} | ||
for (let key in headers) { | ||
this.response.set(key, response.headers[key]) | ||
} | ||
this.body = data; | ||
this.status = 201; | ||
debug(`RestQL: Succeed and Goodbye`) | ||
yield next; | ||
yield* next | ||
} | ||
} | ||
const put = (method, model, association) => { | ||
function parseQuery (model, options) { | ||
return function * (next) { | ||
let body = this.request.body || (yield parse(this)) | ||
, params = this.params | ||
, id = params.id | ||
, include = association ? [association] : [] | ||
, data, status; | ||
let debugInfo = `put ${this.request.url}, using model: ${model.name}`; | ||
const { | ||
method, querystring | ||
} = this.request | ||
if (association) { | ||
debugInfo += `, with association: | ||
${common.getAssociationName(association)}`; | ||
const query = this.restql.query || qs.parse(querystring, options.qs || {}) | ||
this.restql.query = | ||
common.parseQuery(query, model, method.toLowerCase(), options) | ||
yield* next | ||
} | ||
} | ||
function findById (model, query) { | ||
return function * (next) { | ||
const q = query || this.restql.query || {} | ||
const id = this.params.id | ||
if (!id) { | ||
return yield* next | ||
} | ||
const data = | ||
yield model.findById(id, { | ||
attributes : q.attributes, | ||
include : q.include | ||
}) | ||
if (!data) { | ||
this.throw(`RestQL: ${model.name} ${id} cannot be found`, 404) | ||
} | ||
debug(debugInfo); | ||
this.restql.params.id = data | ||
this.restql.response.body = data | ||
data = yield model.findOne({ | ||
where: { id }, | ||
include | ||
}) | ||
yield* next | ||
} | ||
} | ||
function findOne (model, query) { | ||
return function * (next) { | ||
const { | ||
request, response | ||
} = this.restql | ||
const q = query || this.restql.query || {} | ||
const data = | ||
yield model.findOne({ | ||
attributes : q.attributes, | ||
include : q.include, | ||
where : q.where, | ||
}) | ||
if (!data) { | ||
this.throw(`${model.name} ${id} is not found`, 404); | ||
this.throw(`RestQL: ${model.name} cannot be found`, 404) | ||
} | ||
if (!association) { | ||
yield data.update(body); | ||
response.body = data | ||
yield* next | ||
} | ||
} | ||
function pagination (model) { | ||
return function * (next) { | ||
const { | ||
response, params, query | ||
} = this.restql | ||
const { | ||
count, rows | ||
} = response.body | ||
const { | ||
offset, limit | ||
} = query | ||
let status = 200 | ||
const xRangeHeader = `objects ${offset}-${offset + rows.length}/${count}` | ||
if (count > limit) | ||
status = 206 | ||
response.headers = response.headers || {} | ||
response.headers['X-Range'] = xRangeHeader | ||
response.body = rows | ||
response.status = status | ||
yield* next | ||
} | ||
} | ||
function upsert (model) { | ||
return function * (next) { | ||
const { | ||
request, response | ||
} = this.restql | ||
let status = 200 | ||
if (Array.isArray(request.body)) { | ||
return yield* next | ||
} | ||
const result = | ||
yield* _upsert.call(this, model, request.body) | ||
const created = result.created | ||
const data = result.data | ||
if (created) | ||
status = 201 | ||
response.body = data | ||
response.status = status | ||
yield* next | ||
} | ||
} | ||
function findOrUpsert (model) { | ||
return function * (next) { | ||
const { | ||
request, response | ||
} = this.restql | ||
let status = 200 | ||
if (Array.isArray(request.body)) { | ||
return yield* next | ||
} | ||
const { | ||
existingRows, newRows | ||
} = yield* _findExistingRows.call(this, model, [ request.body ]) | ||
let data | ||
if (newRows.length){ | ||
status = 201 | ||
let ret = | ||
yield* _upsert.call(this, model, newRows[0]) | ||
if (ret.created) | ||
status = 201 | ||
data = ret.data | ||
} else { | ||
let associationId = params.associationId; | ||
data = existingRows[0] | ||
} | ||
response.body = data | ||
response.status = status | ||
if (associationId) { | ||
/* | ||
* plural assocation | ||
*/ | ||
data = yield association.target.findOne({ | ||
where: { | ||
id: associationId | ||
} | ||
}); | ||
yield* next | ||
if (!data) { | ||
this.throw(`${model.name} ${id} | ||
association ${associationId} is not found`, 404); | ||
} | ||
} | ||
} | ||
delete body.id; | ||
yield data.update(body); | ||
} else { | ||
/* | ||
* singular association | ||
*/ | ||
let name = association.options.name.singular | ||
, setter = `set${capitalizeFirstLetter(name)}`; | ||
let associationData = data[name]; | ||
function bulkUpsert (model) { | ||
return function * (next) { | ||
delete body.id; | ||
if (!associationData) { | ||
associationData = yield create(association.target, body); | ||
status = 201; | ||
yield data[setter](associationData); | ||
} else { | ||
yield associationData.update(body); | ||
const { | ||
request, response | ||
} = this.restql | ||
const body = request.body | ||
const status = 200 | ||
if (!Array.isArray(body)) { | ||
return yield* next | ||
} | ||
const data = | ||
yield* _bulkUpsert.call(this, model, body) | ||
response.body = data | ||
response.status = status | ||
yield* next | ||
} | ||
} | ||
function bulkFindOrUpsert (model) { | ||
return function * (next) { | ||
const { | ||
request, response | ||
} = this.restql | ||
const status = 200 | ||
if (!Array.isArray(request.body)) { | ||
return yield* next | ||
} | ||
const { | ||
existingRows, newRows | ||
} = yield* _findExistingRows.call(this, model, request.body) | ||
let data = [] | ||
if (newRows.length){ | ||
data = | ||
yield* _bulkUpsert.call(this, model, newRows) | ||
} | ||
data.forEach(row => existingRows.push(row)) | ||
response.body = existingRows | ||
response.status = status | ||
yield* next | ||
} | ||
} | ||
function create (model) { | ||
return function * (next) { | ||
const { | ||
request, response | ||
} = this.restql | ||
const body = request.body | ||
const status = 201 | ||
if (Array.isArray(body)) { | ||
return yield* next | ||
} | ||
const include = [] | ||
const associations = model.associations | ||
const associationList = Object.keys(associations) | ||
for (let key in body) { | ||
let value = body[key] | ||
if ('object' === typeof value) { | ||
if (associationList.indexOf(key) !== -1) { | ||
include.push(associations[key]) | ||
} | ||
data = associationData; | ||
} | ||
} | ||
const data = | ||
yield* _create.call(this, model, body, { | ||
include | ||
}) | ||
this.body = data; | ||
this.status = status || 200; | ||
response.body = data | ||
response.status = status | ||
yield next; | ||
return yield* next | ||
} | ||
} | ||
const del = (method, model, association) => { | ||
function bulkCreate (model) { | ||
return function * (next) { | ||
let params = this.params | ||
, id = params.id | ||
, include = association ? [association] : [] | ||
, data = null; | ||
const { | ||
request, response | ||
} = this.restql | ||
let debugInfo = `delete ${this.request.url}, using model: ${model.name}`; | ||
const body = request.body | ||
const status = 201 | ||
if (association) { | ||
debugInfo += `, with association: | ||
${common.getAssociationName(association)}`; | ||
if (!Array.isArray(body)) { | ||
return yield* next | ||
} | ||
debug(debugInfo); | ||
const data = | ||
yield* _bulkCreate.call(this, model, body) | ||
data = yield model.findOne({ | ||
where: { id }, | ||
include | ||
}) | ||
response.body = data | ||
response.status = status | ||
if (!data) { | ||
this.throw(`${model.name} ${id} is not found`, 404); | ||
} | ||
yield* next | ||
} | ||
} | ||
if (!association) { | ||
yield model.destroy({ | ||
where: { | ||
id: data.id | ||
} | ||
}); | ||
} else { | ||
let associationId = params.associationId | ||
, name = association.options.name.singular | ||
, remove = `remove${capitalizeFirstLetter(name)}` | ||
, associationModel = association.target | ||
, associationData = null; | ||
function parseRequestBody (allowedTypes) { | ||
return function * (next) { | ||
if (associationId) { | ||
/* | ||
* plural assocation | ||
*/ | ||
associationData = yield associationModel.findOne({ | ||
where: { | ||
id: associationId | ||
} | ||
}); | ||
const body = this.request.body | ||
|| this.restql.request.body | ||
|| (yield parse(this)) | ||
if (!data) { | ||
this.throw(`${model.name} ${id} | ||
association ${associationId} is not found`, 404); | ||
} | ||
this.restql.request.body = this.request.body = body | ||
let associationType = association.associationType; | ||
if (associationType === 'HasMany') { | ||
yield associationModel.destroy({ | ||
where: { | ||
id: associationId | ||
} | ||
}) | ||
} else { | ||
yield data[remove](associationData); | ||
} | ||
} else { | ||
/* | ||
* singular association | ||
*/ | ||
if (!allowedTypes) { | ||
return yield* next | ||
} | ||
associationData = data[name]; | ||
const validators = {} | ||
allowedTypes.forEach(type => { | ||
validators[type] = true | ||
}) | ||
if (associationData) { | ||
yield associationModel.destroy({ | ||
where: { | ||
id: associationData.id | ||
} | ||
}); | ||
} | ||
} | ||
validators.defaults = () => { | ||
this.throw(`RestQL: ${allowedTypes.join()} body are supported`, 400) | ||
} | ||
this.data = {}; | ||
this.status = 204; | ||
common.switchByType(body, validators) | ||
yield next; | ||
yield* next | ||
} | ||
} | ||
module.exports.handlers = { | ||
get, post, put, del | ||
}; | ||
function destroy (model) { | ||
return function * (next) { | ||
const query = this.restql.query || {} | ||
const where = query.where || {} | ||
const status = 204 | ||
yield model.destroy({ | ||
where | ||
}) | ||
this.restql.response.status = status | ||
yield* next | ||
} | ||
} | ||
module.exports.before = before | ||
module.exports.after = after | ||
module.exports.pagination = pagination | ||
module.exports.parseRequestBody = parseRequestBody | ||
module.exports.parseQuery = parseQuery | ||
module.exports.upsert = upsert | ||
module.exports.bulkUpsert = bulkUpsert | ||
module.exports.findOrUpsert = findOrUpsert | ||
module.exports.bulkFindOrUpsert = bulkFindOrUpsert | ||
module.exports.create = create | ||
module.exports.bulkCreate = bulkCreate | ||
module.exports.destroy = destroy | ||
module.exports.findOne = findOne | ||
module.exports.findById = findById |
@@ -18,10 +18,20 @@ 'use strict'; | ||
function RestQL (models, opts) { | ||
function RestQL (models, options) { | ||
if (!(this instanceof RestQL)) { | ||
return new RestQL(models, opts); | ||
return new RestQL(models, options); | ||
} | ||
this.options = opts || {}; | ||
const defaultOptions = { | ||
query: { | ||
_limit: 20 | ||
}, | ||
qs: { | ||
allowDots : true, | ||
strictNullHandling : true | ||
} | ||
} | ||
this.options = options || defaultOptions | ||
if (!models) { | ||
@@ -31,8 +41,8 @@ throw new Error('paramter models does not exist') | ||
this.models = models; | ||
this.router = router.load(models, this.options); | ||
this.models = models | ||
this.router = router.load(models, this.options) | ||
this.routes = () => { | ||
return this.router.routes(); | ||
return this.router.routes() | ||
} | ||
} |
'use strict'; | ||
const debug = require('debug')('koa-restql:router'); | ||
const Router = require('koa-router'); | ||
const Router = require('koa-router'); | ||
const debug = require('debug')('koa-restql:router'); | ||
const common = require('./common'); | ||
const methods = require('./methods'); | ||
const middlewares = require('./middlewares'); | ||
const common = require('./common'); | ||
const methods = require('./methods'); | ||
const loaders = require('./loaders'); | ||
const handlers = middlewares.handlers; | ||
const global = {}; | ||
const methodShouldMount = (path, method, options) => { | ||
function loadModelRoutes (router, method, model, name, options) { | ||
options = options || {}; | ||
let base = `/${name}` | ||
, associations = model.associations | ||
, schema = model.options.schema; | ||
let ignore = options.ignore; | ||
if (schema) { | ||
base = `/${schema}${base}`; | ||
} | ||
if (common.shouldIgnoreAssociation(method, options)) | ||
return false; | ||
if (method.isSingular !== undefined && method.isSingular !== path.isSingular) | ||
return false; | ||
return true; | ||
} | ||
const createModelRoutes = (path, model, association, options) => { | ||
let models = global.models || {} | ||
, router = global.router || {} | ||
, paths = [ path ]; | ||
/** | ||
* if association === undefined and path is plural, | ||
* mount /path | ||
* and /path/:id to router | ||
* | ||
* else if there is a association | ||
* and if this path is singular | ||
* mount /path | ||
* | ||
* and if this path is plural | ||
* mount /path | ||
* and /path/:associationId | ||
*/ | ||
if (!association || !path.isSingular) { | ||
let id = !association ? ':id' : ':associationId'; | ||
paths.push({ name: `${path.name}/${id}`, isSingular: true }); | ||
let loader = loaders.model[method]; | ||
if (loader) { | ||
loader(router, base, model, options) | ||
} | ||
paths.forEach(path => { | ||
methods.forEach(method => { | ||
if (methodShouldMount(path, method, options)) { | ||
let args = [method, model] | ||
, name = method.name; | ||
Object.keys(associations).forEach(key => { | ||
if (association) | ||
args.push(association); | ||
let association = associations[key] | ||
, isSingular = association.isSingleAssociation | ||
, associationType = association.associationType | ||
, loaderPath = loaders.model.association | ||
, loader | ||
debug(path.name); | ||
router[name](path.name, handlers[name].apply(this, args)); | ||
} | ||
}) | ||
}) | ||
/*** | ||
* to camel case | ||
*/ | ||
associationType = | ||
associationType.replace(/^(.)/, $1 => $1.toLowerCase()) | ||
if (association || !model.associations) | ||
return; | ||
loaderPath = isSingular ? loaderPath.singular : loaderPath.plural | ||
loader = (loaderPath[associationType] && | ||
loaderPath[associationType][method]) || loaderPath[method] | ||
Object.keys(model.associations).forEach(key => { | ||
let association = model.associations[key] | ||
, options = association.options | ||
, isSingular = !! association.isSingleAssociation | ||
, pathName = paths[1].name.slice(); | ||
pathName += `/${common.getAssociationName(association)}`; | ||
createModelRoutes({ name: pathName, isSingular }, model, association, options.restql); | ||
if (loader) { | ||
loader(router, `${base}/:id/${key}`, model, association, options) | ||
} | ||
}) | ||
} | ||
module.exports.load = (models, opts) => { | ||
function load (models, options) { | ||
let router = new Router(); | ||
global.models = models; | ||
global.router = router; | ||
Object.keys(models).forEach(key => { | ||
let model = models[key] | ||
, schema = model.options.schema | ||
, path = `/${key}`; | ||
let model = models[key]; | ||
if (schema) { | ||
path = `/${schema}${path}`; | ||
} | ||
methods.forEach(method => { | ||
loadModelRoutes(router, method.toLowerCase(), model, key, options); | ||
}) | ||
}) | ||
createModelRoutes({ name: path, isSingular: false }, model); | ||
}) | ||
return router; | ||
} | ||
module.exports.methodShouldMount = methodShouldMount; | ||
module.exports.load = load; |
{ | ||
"name": "koa-restql", | ||
"version": "0.0.1", | ||
"version": "0.1.0", | ||
"description": "Koa RESTful API middleware based on Sequlizejs", | ||
@@ -26,3 +26,3 @@ "main": "lib/RestQL.js", | ||
"node-uuid": "^1.4.7", | ||
"sequelize": "^3.23.2", | ||
"sequelize": "^3.23.6", | ||
"glob": "^7.0.3", | ||
@@ -29,0 +29,0 @@ "mocha": "^2.3.4", |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
41399
7
1443
1