diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..21e9920 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,52 @@ +module.exports = { + env: { + browser: true, + commonjs: true, + es6: true, + }, + extends: [ + 'airbnb-base', + ], + globals: { + Atomics: 'readonly', + SharedArrayBuffer: 'readonly', + }, + parserOptions: { + ecmaVersion: 2018, + }, + rules: { + "import/no-named-as-default-member": 0, + "import/no-named-as-default": 0, + "no-tabs": 0, + "camelcase": 0, + "no-console": 0, + "no-param-reassign": 0, + "import/prefer-default-export": 0, + "consistent-return": 0, + "max-len": 0, + "no-continue": 0, + 'no-case-declarations': 0, + "indent": [2, "tab", { "SwitchCase": 1, "VariableDeclarator": 1 }], + "class-methods-use-this": 0, + "no-restricted-syntax": 0, + "prefer-template": 0, + "no-plusplus": 0, + "default-case": 0, + "no-useless-constructor": 0, + "jsx-a11y/accessible-emoji": 0, + "no-use-before-define": 0, + "curly": 0, + "no-unused-expressions": ["error", { "allowShortCircuit": true }], + "prefer-destructuring": 0, + "no-await-in-loop": 0, + "global-require": 0, + "func-names": 0, + "linebreak-style": 0, + "no-empty-function": 0, + "no-labels": 0, + "func-names": 0, + "guard-for-in": 0, + "radix": 0, + "import/no-dynamic-require": 0, + }, +}; diff --git a/.gitignore b/.gitignore index 59d842b..929c476 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ build/Release # Commenting this out is preferred by some people, see # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- node_modules +package-lock.json # Users Environment Variables .lock-wscript diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be85aa7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "eslint.autoFixOnSave": true +} \ No newline at end of file diff --git a/package.json b/package.json index 0a7ad1f..047c2d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kalamata", - "version": "0.1.4", + "version": "0.2.0", "description": "Extensible REST API for Express + Bookshelf.js", "homepage": "https://github.com/mikec/kalamata", "bugs": "https://github.com/mikec/kalamata/issues", @@ -22,6 +22,9 @@ "bookshelf": "^0.10" }, "devDependencies": { + "eslint": "^5.16.0", + "eslint-config-airbnb-base": "^13.2.0", + "eslint-plugin-import": "^2.18.0", "grunt": "0.4.5", "grunt-contrib-watch": "0.6.1", "grunt-jasmine-node": "git://github.com/fiznool/grunt-jasmine-node.git#c773421b608ce944454cb540a6e66575d2df09c6", @@ -29,7 +32,8 @@ }, "dependencies": { "body-parser": "^1.9.0", - "bluebird": "^3" + "bluebird": "^3", + "qs": "^6.7.0" }, "scripts": { "test": "grunt" diff --git a/src/index.js b/src/index.js index c07007e..c615b7b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,473 +1,544 @@ -var bodyParser = require('body-parser'); -var Promise = require("bluebird"); -var app, options; -var hooks = {}; -var modelMap = {}; -var identifierMap = {}; -var modelNameMap = {}; -var collectionNameMap = {}; - -var kalamata = module.exports = function(_app_, _options_) { - app = _app_; - options = _options_; - - app.use(bodyParser.json()); - - if(!options) options = {}; - if(!options.apiRoot) options.apiRoot = '/'; - else options.apiRoot = '/' + options.apiRoot.replace(/^\/|\/$/g, '') + '/'; - - return kalamata; +const bodyParser = require('body-parser'); +const Promise = require('bluebird'); +const Qs = require('qs'); + +let app; let + options; +const hooks = {}; +const modelMap = {}; +const identifierMap = {}; +const modelNameMap = {}; +const collectionNameMap = {}; + +const kalamata = module.exports = function (_app_, _options_) { + app = _app_; + options = _options_; + + app.use(bodyParser.json()); + + if (!options) options = {}; + if (!options.apiRoot) options.apiRoot = '/'; + else options.apiRoot = '/' + options.apiRoot.replace(/^\/|\/$/g, '') + '/'; + + return kalamata; }; -kalamata.expose = function(model, _opts_) { - - var validOpts = { - identifier: true, - endpointName: true, - modelName: true, - collectionName: true - }; - - var opts = {}; - - for(var p in _opts_) { - if(validOpts[p]) { - opts[p] = _opts_[p]; - } else { - throw new Error('Invalid option: ' + p); - } - } - - if(!opts.identifier) opts.identifier = 'id'; - if(!opts.endpointName) opts.endpointName = model.forge().tableName; - if(!opts.modelName) opts.modelName = capitalize(modelName(model.forge().tableName)); - if(!opts.collectionName) opts.collectionName = collectionName(model.forge().tableName); - opts.modelName = capitalize(opts.modelName); - opts.collectionName = capitalize(opts.collectionName); - - modelMap[opts.endpointName] = model; - identifierMap[opts.endpointName] = opts.identifier; - - hooks[opts.endpointName] = { - before: hookArrays(), - after: hookArrays() - }; - - var beforeHooks = hooks[opts.endpointName].before; - var afterHooks = hooks[opts.endpointName].after; - - var modelNameLower = decapitalize(opts.modelName); - var collectionNameLower = decapitalize(opts.collectionName); - - modelMap[modelNameLower] = - modelMap[collectionNameLower] = model; - identifierMap[modelNameLower] = - identifierMap[collectionNameLower] = opts.identifier; - modelNameMap[collectionNameLower] = modelNameLower; - collectionNameMap[modelNameLower] = collectionNameLower; - - createHookFunctions(); - configureEndpoints(); - - function configureEndpoints() { - - app.get(options.apiRoot + opts.endpointName, function(req, res, next) { - var mod; - if(req.query.where) { - var w; - try { - w = parseJSON(req.query.where); - } catch(err) { - throw new Error('Could not parse JSON: ' + req.query.where); - } - mod = new model().where(w); - } else { - mod = new model(); - } - - var beforeResult = runHooks(beforeHooks.getCollection, [req, res, mod]); - if(res.headersSent) return; - - var promise = beforeResult.promise || mod.fetchAll(getFetchParams(req, res)); - promise.then(function(collection) { - var afterResult = runHooks(afterHooks.getCollection, [req, res, collection]); - return afterResult.promise || collection; - }).then(function(collection) { - sendResponse(res, collection.toJSON()); - }).catch(next); - }); - - app.get(options.apiRoot + opts.endpointName + '/:identifier', - function(req, res, next) { - var mod = new model(getModelAttrs(req)); - - var beforeResult = runHooks(beforeHooks.get, [req, res, mod]); - if(res.headersSent) return; - - var promise = beforeResult.promise || mod.fetch(getFetchParams(req, res)); - promise.then(function(m) { - return checkModelFetchSuccess(req, m); - }).then(function(m) { - var afterResult = runHooks(afterHooks.get, [req, res, m]); - return afterResult.promise || m; - }).then(function(m) { - sendResponse(res, m); - }).catch(next); - }); - - app.get(options.apiRoot + opts.endpointName + '/:identifier/:relation', - function(req, res, next) { - var mod = new model(getModelAttrs(req)); - mod.fetch({ - withRelated: getWithRelatedArray([req.params.relation], req, res) - }).then(function(m) { - return checkModelFetchSuccess(req, m); - }).then(function(m) { - return m.related(req.params.relation); - }).then(function(related) { - var afterResult = {}; - var relHooks = hooks[req.params.relation]; - if(relHooks) { - afterResult = runHooks( - hooks[req.params.relation].after.getRelated, - [req, res, related, mod]); - } - return afterResult.promise || related; - }).then(function(related) { - sendResponse(res, related); - }).catch(next); - }); - - app.post(options.apiRoot + opts.endpointName, function(req, res, next) { - var mod = new model(req.body); - - var beforeResult = runHooks(beforeHooks.create, [req, res, mod]); - if(res.headersSent) return; - - var promise = beforeResult.promise || mod.save(); - promise.then(function(m) { - if(m) { - var afterResult = runHooks(afterHooks.create, [req, res, m]); - return afterResult.promise || m; - } - }).then(function(m) { - if(m) { - sendResponse(res, m.toJSON()); - } - }).catch(next); - }); - - app.post(options.apiRoot + opts.endpointName + '/:identifier/:relation', - function(req, res, next) { - var rel = req.params.relation; - var rModel = modelMap[rel]; - var rId = identifierMap[rel]; - var mod = new model(getModelAttrs(req)); - var relMod; - - var beforeResult = runMultiHooks( - [hooks[req.params.relation].before.relate, - [req, res, mod]], - [beforeHooks.relate, - [req, res, mod]]); - if(res.headersSent) return; - - var promise; - if(beforeResult.promise) { - promise = beforeResult.promise; - } else { - promise = mod.fetch(); - } - - promise.then(function(m) { - if(req.body[rId]) { - // fetch and add an existing model - return (new rModel(req.body)).fetch().then(function(rMod) { - if(rMod) { - relMod = rMod; - var relCollection = m.related(rel); - if(relCollection.create) { - // for hasMany relations - return relCollection.create(rMod); - } else { - // for belongsTo relations, reverse it - return rMod.related(opts.endpointName).create(m); - } - } else { - throw new Error('Create relationship failed: ' + - 'Could not find ' + rel + - ' model ' + JSON.stringify(req.body)); - } - }); - } else { - throw new Error('Create relationship failed: ' + - rId + ' property not provided'); - } - }).then(function() { - var afterResult = runMultiHooks( - [hooks[req.params.relation].after.relate, - [req, res, mod, relMod]], - [afterHooks.relate, - [req, res, mod, relMod]]); - return afterResult.promise || null; - return null; - }).then(function() { - sendResponse(res, null); - }).catch(next); - }); - - app.put(options.apiRoot + opts.endpointName + '/:identifier', - function(req, res, next) { - new model(getModelAttrs(req)).fetch().then(function(m) { - if(m) m.set(req.body); - var beforeResult = runHooks(beforeHooks.update, [req, res, m]); - if(!res.headersSent) { - if(m) { - return beforeResult.promise || m.save(); - } else { - return checkModelFetchSuccess(req, m); - } - } - }).then(function(m) { - if(m) { - var afterResult = runHooks(afterHooks.update, [req, res, m]); - return afterResult.promise || m; - } - }).then(function(m) { - if(m) { - sendResponse(res, m.toJSON()); - } - }).catch(next); - }); - - app.delete(options.apiRoot + opts.endpointName + '/:identifier', - function(req, res, next) { - new model(getModelAttrs(req)).fetch().then(function(m) { - var beforeResult = runHooks(beforeHooks.del, [req, res, m]); - if(!res.headersSent) { - if(m) { - return beforeResult.promise || m.destroy(); - } else { - return checkModelFetchSuccess(req, m); - } - } - }).then(function(m) { - if(m) { - var afterResult = runHooks(afterHooks.del, [req, res, m]); - return afterResult.promise || m; - } - }).then(function() { - sendResponse(res, true); - }).catch(next); - }); - - app.delete(options.apiRoot + opts.endpointName + '/:identifier/:relation', - function(req, res, next) { - var rel = req.params.relation; - var mod = new model(getModelAttrs(req)); - mod.fetch().then(function(m) { - var fKey = m[rel]().relatedData.foreignKey; - return m.set(fKey, null).save(); - }).then(function() { - sendResponse(res, true); - }).catch(next); - }); - - app.delete(options.apiRoot + opts.endpointName + '/:identifier/:relation/:rIdentifier', - function(req, res, next) { - var rel = req.params.relation; - var rModel = modelMap[rel]; - var rId = identifierMap[rel]; - var mod = new model(getModelAttrs(req)); - var relMod; - - mod.fetch().then(function(m) { - var rModAttrs = {}; - rModAttrs[rId] = req.params.rIdentifier; - return (new rModel(rModAttrs)).fetch().then(function(rMod) { - if(rMod) { - var modelName = modelNameMap[opts.endpointName]; - var fKey = rMod[modelName]().relatedData.foreignKey; - return rMod.set(fKey, null).save(); - } else { - throw new Error('Delete relationship failed: ' + - 'Could not find ' + rel + - ' model ' + JSON.stringify(rModAttrs)); - } - }); - }).then(function() { - sendResponse(res, true); - }).catch(next); - - }); - } - - function runMultiHooks() { - var promiseResults = []; - for(var i in arguments) { - var res = runHooks.apply(null, arguments[i]); - if(res.promise) { - promiseResults.push(res.promise); - } - } - if(promiseResults.length > 0) { - var ret = Promise.all(promiseResults).then(function() { - var args = arguments[0]; - return new Promise(function(resolve) { - resolve.apply(null, args); - }); - }); - return { promise: ret }; - } else { - return {}; - } - } - - function runHooks(fnArray, args) { - var result; - for(var i in fnArray) { - result = fnArray[i].apply(null, args); - } - if(result && result.then) { - return { - promise: result - }; - } else { - return {}; - } - } - - function hookArrays() { - return { - get: [], - getCollection: [], - getRelated: [], - create: [], - update: [], - del: [], - relate: [] - }; - } - - function createHookFunctions() { - createHookFunction('beforeGet' + opts.collectionName, - 'before', 'getCollection'); - createHookFunction('beforeGetRelated' + opts.collectionName, - 'before', 'getRelated'); - createHookFunction('beforeGet' + opts.modelName, 'before', 'get'); - createHookFunction('beforeCreate' + opts.modelName, 'before', 'create'); - createHookFunction('beforeUpdate' + opts.modelName, 'before', 'update'); - createHookFunction('beforeDelete' + opts.modelName, 'before', 'del'); - createHookFunction('beforeRelate' + opts.modelName, 'before', 'relate'); - createHookFunction('afterGet' + opts.collectionName, - 'after', 'getCollection'); - createHookFunction('afterGetRelated' + opts.collectionName, - 'after', 'getRelated'); - createHookFunction('afterGet' + opts.modelName, 'after', 'get'); - createHookFunction('afterCreate' + opts.modelName, 'after', 'create'); - createHookFunction('afterUpdate' + opts.modelName, 'after', 'update'); - createHookFunction('afterDelete' + opts.modelName, 'after', 'del'); - createHookFunction('afterRelate' + opts.modelName, 'after', 'relate'); - } - - function createHookFunction(fnName, prefix, type) { - kalamata[fnName] = hookFn(prefix, type, fnName); - } - - function hookFn(prefix, type, fnName) { - if(type) { - return function(fn) { - fn.__name = fnName; - hooks[opts.endpointName][prefix][type].push(fn); - }; - } else { - return function(fn) { - fn.__name = fnName; - for(var i in hooks[prefix]) { - hooks[opts.endpointName][prefix][i].push(fn); - } - }; - } - } - - function checkModelFetchSuccess(req, m) { - if(!m) { - throw new Error( - req.method + ' ' + req.url + ' failed: ' + - opts.identifier + ' = ' + req.params.identifier + - ' not found' - ); - } - return m; - } - - function getModelAttrs(req) { - var attrs; - if(req.params.identifier) { - attrs = {}; - attrs[opts.identifier] = req.params.identifier; - } - return attrs; - } - - function getWithRelatedArray(related, req, res) { - var relArray = []; - for(var i in related) { - var r = related[i]; - var relHooks = hooks[r]; - if(relHooks) { - var relObj = {}; - relObj[r] = function(qb) { - runHooks(relHooks.before.getRelated, [req, res, qb]); - }; - relArray.push(relObj); - } else { - relArray.push(r); - } - } - return relArray; - } - - function getFetchParams(req, res) { - return req.query.load ? { - withRelated: getWithRelatedArray(req.query.load.split(','), req, res) - } : null; - } - - function sendResponse(response, sendData) { - if(!response.headersSent) response.send(sendData); - } - - function collectionName(endpointName) { - endpointName = capitalize(endpointName); - endpointName += (endpointName.slice(-1) == 's' ? '' : 'Collection'); - return endpointName; - } - - function modelName(endpointName) { - endpointName = (endpointName.slice(-1) == 's' ? - endpointName.substr(0,endpointName.length - 1) : - endpointName); - return capitalize(endpointName); - } - - function decapitalize(str) { - return str.charAt(0).toLowerCase() + str.slice(1); - } - - function capitalize(str) { - return str.charAt(0).toUpperCase() + str.slice(1); - } - - function parseJSON(str) { - return JSON.parse(fixJSONString(str)); - } - - function fixJSONString(str) { - return str.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": '); - } - - return kalamata; - -}; \ No newline at end of file +kalamata.expose = function (model, _opts_) { + const validOpts = { + identifier: true, + endpointName: true, + modelName: true, + collectionName: true, + }; + + const opts = {}; + + for (const p in _opts_) { + if (validOpts[p]) { + opts[p] = _opts_[p]; + } else { + throw new Error('Invalid option: ' + p); + } + } + + if (!opts.identifier) opts.identifier = 'id'; + if (!opts.endpointName) opts.endpointName = model.forge().tableName; + if (!opts.modelName) opts.modelName = capitalize(modelName(model.forge().tableName)); + if (!opts.collectionName) opts.collectionName = collectionName(model.forge().tableName); + opts.modelName = capitalize(opts.modelName); + opts.collectionName = capitalize(opts.collectionName); + + modelMap[opts.endpointName] = model; + identifierMap[opts.endpointName] = opts.identifier; + + hooks[opts.endpointName] = { + before: hookArrays(), + after: hookArrays(), + }; + + const beforeHooks = hooks[opts.endpointName].before; + const afterHooks = hooks[opts.endpointName].after; + + const modelNameLower = decapitalize(opts.modelName); + const collectionNameLower = decapitalize(opts.collectionName); + + modelMap[modelNameLower] = modelMap[collectionNameLower] = model; + identifierMap[modelNameLower] = identifierMap[collectionNameLower] = opts.identifier; + modelNameMap[collectionNameLower] = modelNameLower; + collectionNameMap[modelNameLower] = collectionNameLower; + + createHookFunctions(); + configureEndpoints(); + + function configureEndpoints() { + app.get(options.apiRoot + opts.endpointName, (req, res, next) => { + // initialize our DB request. + let mod = new model(); + + if (req.query.where) { + let where; + + // the query string must be formatted as json. + try { + where = parseJSON(req.query.where); + } catch (err) { + throw new Error('Could not parse JSON: ' + req.query.where); + } + + // if the "where" was successfully parsed, we can chain it on to the request. + if (where) { + mod = mod.where(where); + } + } + + return Promise.resolve().then(() => { + // If we send page or page_size in the query, we want to limit the query + if (req.query.page || req.query.page_size) { + // By default, we return the first page with 100 items + const { page = 1, page_size = 100 } = req.query; + const page_number = parseInt(page, 10); + const page_size_number = parseInt(page_size, 10); + + // Get the number of pages and the number of items + return mod.clone().count('id').then(total_items => ({ + total_items, + total_pages: Math.ceil(total_items / page_size_number), + })).then(({ total_items, total_pages }) => { + // If the page number is greater than the number of pages, we return an empty array + // Limit and offset are creating a loop: we will have the first page if we request total_pages + 1 + // We want to avoid this loop to happen + if (page_number > total_pages) { + sendResponse(res, []); + return; + // If it is the last page, we return only the last elements of the request + // If we don't do this, we will have some of the first elements in the last page + } if (page_number === total_pages) { + const remaining_items = total_items - (page_number - 1) * page_size_number; + + mod = mod.orderBy('id', 'DESC').query(qb => qb.limit(remaining_items).offset((page_number - 1) * page_size_number)); + // otherwise, return the elements of the page requested + } else { + mod = mod.orderBy('id', 'DESC').query(qb => qb.limit(page_size_number).offset((page_number - 1) * page_size_number)); + } + + // Add headers in res with links to previous and next pages + // Add also the number of pages and the number of items per page + if (!(page_number === 0)) { + const prev_query = Object.assign({}, req.query); + // decrement page number. + prev_query.page_number = page_number - 1; + // set page size explicitly just to be safe. + prev_query.page_size_number = page_size_number; + + res.header( + 'x-prev', + `${req.route.path}?${Qs.stringify(prev_query)}`, + ); + } + if (!(page_number === total_pages)) { + const next_query = Object.assign({}, req.query); + // increment page number. + next_query.page_number = page_number + 1; + // set page size explicitly just to be safe. + next_query.page_size_number = page_size_number; + + res.header( + 'x-next', + `${req.route.path}?${Qs.stringify(next_query)}`, + ); + } + res.header('x-total-pages', total_pages); + res.header('x-total-items', total_items); + }); + } + }).then(() => { + if (res.headersSent) return; + + // before hook + const beforeResult = runHooks(beforeHooks.getCollection, [req, res, mod]); + + return beforeResult.promise || mod.fetchAll(getFetchParams(req, res)); + }).then((collection) => { + if (res.headersSent) return; + + // after hook + const afterResult = runHooks(afterHooks.getCollection, [req, res, collection]); + + return afterResult.promise || collection; + }) + .then((collection) => { + if (res.headersSent) return; + + // send final response + sendResponse(res, collection.toJSON()); + }) + .catch(next); + }); + + app.get(options.apiRoot + opts.endpointName + '/:identifier', + (req, res, next) => { + const mod = new model(getModelAttrs(req)); + + const beforeResult = runHooks(beforeHooks.get, [req, res, mod]); + if (res.headersSent) return; + + const promise = beforeResult.promise || mod.fetch(getFetchParams(req, res)); + promise.then(m => checkModelFetchSuccess(req, m)).then((m) => { + const afterResult = runHooks(afterHooks.get, [req, res, m]); + return afterResult.promise || m; + }).then((m) => { + sendResponse(res, m); + }).catch(next); + }); + + app.get(options.apiRoot + opts.endpointName + '/:identifier/:relation', + (req, res, next) => { + const mod = new model(getModelAttrs(req)); + mod.fetch({ + withRelated: getWithRelatedArray([req.params.relation], req, res), + }).then(m => checkModelFetchSuccess(req, m)).then(m => m.related(req.params.relation)).then((related) => { + let afterResult = {}; + const relHooks = hooks[req.params.relation]; + if (relHooks) { + afterResult = runHooks( + hooks[req.params.relation].after.getRelated, + [req, res, related, mod], + ); + } + return afterResult.promise || related; + }) + .then((related) => { + sendResponse(res, related); + }) + .catch(next); + }); + + app.post(options.apiRoot + opts.endpointName, (req, res, next) => { + const mod = new model(req.body); + + const beforeResult = runHooks(beforeHooks.create, [req, res, mod]); + if (res.headersSent) return; + + const promise = beforeResult.promise || mod.save(); + promise.then((m) => { + if (m) { + const afterResult = runHooks(afterHooks.create, [req, res, m]); + return afterResult.promise || m; + } + }).then((m) => { + if (m) { + sendResponse(res, m.toJSON()); + } + }).catch(next); + }); + + app.post(options.apiRoot + opts.endpointName + '/:identifier/:relation', + (req, res, next) => { + const rel = req.params.relation; + const rModel = modelMap[rel]; + const rId = identifierMap[rel]; + const mod = new model(getModelAttrs(req)); + let relMod; + + const beforeResult = runMultiHooks( + [hooks[req.params.relation].before.relate, + [req, res, mod]], + [beforeHooks.relate, + [req, res, mod]], + ); + if (res.headersSent) return; + + let promise; + if (beforeResult.promise) { + promise = beforeResult.promise; + } else { + promise = mod.fetch(); + } + + promise.then((m) => { + if (req.body[rId]) { + // fetch and add an existing model + return (new rModel(req.body)).fetch().then((rMod) => { + if (rMod) { + relMod = rMod; + const relCollection = m.related(rel); + if (relCollection.create) { + // for hasMany relations + return relCollection.create(rMod); + } + // for belongsTo relations, reverse it + return rMod.related(opts.endpointName).create(m); + } + throw new Error('Create relationship failed: ' + + 'Could not find ' + rel + + ' model ' + JSON.stringify(req.body)); + }); + } + throw new Error('Create relationship failed: ' + + rId + ' property not provided'); + }).then(() => { + const afterResult = runMultiHooks( + [hooks[req.params.relation].after.relate, + [req, res, mod, relMod]], + [afterHooks.relate, + [req, res, mod, relMod]], + ); + return afterResult.promise || null; + return null; + }).then(() => { + sendResponse(res, null); + }).catch(next); + }); + + app.put(options.apiRoot + opts.endpointName + '/:identifier', + (req, res, next) => { + new model(getModelAttrs(req)).fetch().then((m) => { + if (m) m.set(req.body); + const beforeResult = runHooks(beforeHooks.update, [req, res, m]); + if (!res.headersSent) { + if (m) { + return beforeResult.promise || m.save(); + } + return checkModelFetchSuccess(req, m); + } + }).then((m) => { + if (m) { + const afterResult = runHooks(afterHooks.update, [req, res, m]); + return afterResult.promise || m; + } + }) + .then((m) => { + if (m) { + sendResponse(res, m.toJSON()); + } + }) + .catch(next); + }); + + app.delete(options.apiRoot + opts.endpointName + '/:identifier', + (req, res, next) => { + new model(getModelAttrs(req)).fetch().then((m) => { + const beforeResult = runHooks(beforeHooks.del, [req, res, m]); + if (!res.headersSent) { + if (m) { + return beforeResult.promise || m.destroy(); + } + return checkModelFetchSuccess(req, m); + } + }).then((m) => { + if (m) { + const afterResult = runHooks(afterHooks.del, [req, res, m]); + return afterResult.promise || m; + } + }) + .then(() => { + sendResponse(res, true); + }) + .catch(next); + }); + + app.delete(options.apiRoot + opts.endpointName + '/:identifier/:relation', + (req, res, next) => { + const rel = req.params.relation; + const mod = new model(getModelAttrs(req)); + mod.fetch().then((m) => { + const fKey = m[rel]().relatedData.foreignKey; + return m.set(fKey, null).save(); + }).then(() => { + sendResponse(res, true); + }).catch(next); + }); + + app.delete(options.apiRoot + opts.endpointName + '/:identifier/:relation/:rIdentifier', + (req, res, next) => { + const rel = req.params.relation; + const rModel = modelMap[rel]; + const rId = identifierMap[rel]; + const mod = new model(getModelAttrs(req)); + let relMod; + + mod.fetch().then((m) => { + const rModAttrs = {}; + rModAttrs[rId] = req.params.rIdentifier; + return (new rModel(rModAttrs)).fetch().then((rMod) => { + if (rMod) { + const modelName = modelNameMap[opts.endpointName]; + const fKey = rMod[modelName]().relatedData.foreignKey; + return rMod.set(fKey, null).save(); + } + throw new Error('Delete relationship failed: ' + + 'Could not find ' + rel + + ' model ' + JSON.stringify(rModAttrs)); + }); + }).then(() => { + sendResponse(res, true); + }).catch(next); + }); + } + + function runMultiHooks() { + const promiseResults = []; + for (const i in arguments) { + const res = runHooks.apply(null, arguments[i]); + if (res.promise) { + promiseResults.push(res.promise); + } + } + if (promiseResults.length > 0) { + const ret = Promise.all(promiseResults).then(function () { + const args = arguments[0]; + return new Promise(((resolve) => { + resolve.apply(null, args); + })); + }); + return { promise: ret }; + } + return {}; + } + + function runHooks(fnArray, args) { + let result; + for (const i in fnArray) { + result = fnArray[i].apply(null, args); + } + if (result && result.then) { + return { + promise: result, + }; + } + return {}; + } + + function hookArrays() { + return { + get: [], + getCollection: [], + getRelated: [], + create: [], + update: [], + del: [], + relate: [], + }; + } + + function createHookFunctions() { + createHookFunction('beforeGet' + opts.collectionName, + 'before', 'getCollection'); + createHookFunction('beforeGetRelated' + opts.collectionName, + 'before', 'getRelated'); + createHookFunction('beforeGet' + opts.modelName, 'before', 'get'); + createHookFunction('beforeCreate' + opts.modelName, 'before', 'create'); + createHookFunction('beforeUpdate' + opts.modelName, 'before', 'update'); + createHookFunction('beforeDelete' + opts.modelName, 'before', 'del'); + createHookFunction('beforeRelate' + opts.modelName, 'before', 'relate'); + createHookFunction('afterGet' + opts.collectionName, + 'after', 'getCollection'); + createHookFunction('afterGetRelated' + opts.collectionName, + 'after', 'getRelated'); + createHookFunction('afterGet' + opts.modelName, 'after', 'get'); + createHookFunction('afterCreate' + opts.modelName, 'after', 'create'); + createHookFunction('afterUpdate' + opts.modelName, 'after', 'update'); + createHookFunction('afterDelete' + opts.modelName, 'after', 'del'); + createHookFunction('afterRelate' + opts.modelName, 'after', 'relate'); + } + + function createHookFunction(fnName, prefix, type) { + kalamata[fnName] = hookFn(prefix, type, fnName); + } + + function hookFn(prefix, type, fnName) { + if (type) { + return function (fn) { + fn.__name = fnName; + hooks[opts.endpointName][prefix][type].push(fn); + }; + } + return function (fn) { + fn.__name = fnName; + for (const i in hooks[prefix]) { + hooks[opts.endpointName][prefix][i].push(fn); + } + }; + } + + function checkModelFetchSuccess(req, m) { + if (!m) { + throw new Error( + req.method + ' ' + req.url + ' failed: ' + + opts.identifier + ' = ' + req.params.identifier + + ' not found', + ); + } + return m; + } + + function getModelAttrs(req) { + let attrs; + if (req.params.identifier) { + attrs = {}; + attrs[opts.identifier] = req.params.identifier; + } + return attrs; + } + + function getWithRelatedArray(related, req, res) { + const relArray = []; + for (const i in related) { + const r = related[i]; + var relHooks = hooks[r]; + if (relHooks) { + const relObj = {}; + relObj[r] = function (qb) { + if (relHooks) { + runHooks(relHooks.before.getRelated, [req, res, qb]); + } + }; + relArray.push(relObj); + } else { + relArray.push(r); + } + } + return relArray; + } + + function getFetchParams(req, res) { + return req.query.load ? { + withRelated: getWithRelatedArray(req.query.load.split(','), req, res), + } : null; + } + + function sendResponse(response, sendData) { + if (!response.headersSent) response.send(sendData); + } + + function collectionName(endpointName) { + endpointName = capitalize(endpointName); + endpointName += (endpointName.slice(-1) == 's' ? '' : 'Collection'); + return endpointName; + } + + function modelName(endpointName) { + endpointName = (endpointName.slice(-1) == 's' + ? endpointName.substr(0, endpointName.length - 1) + : endpointName); + return capitalize(endpointName); + } + + function decapitalize(str) { + return str.charAt(0).toLowerCase() + str.slice(1); + } + + function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + function parseJSON(str) { + return JSON.parse(fixJSONString(str)); + } + + function fixJSONString(str) { + return str.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": '); + } + + return kalamata; +};