From 85b06d47ce5c5095fdb8103a8ae329725b445c46 Mon Sep 17 00:00:00 2001 From: Freeman Date: Thu, 27 Apr 2017 16:51:58 +0100 Subject: [PATCH 01/19] Update index.js --- src/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index c07007e..ebfdf11 100644 --- a/src/index.js +++ b/src/index.js @@ -419,7 +419,9 @@ kalamata.expose = function(model, _opts_) { if(relHooks) { var relObj = {}; relObj[r] = function(qb) { - runHooks(relHooks.before.getRelated, [req, res, qb]); + if(relHooks){ + runHooks(relHooks.before.getRelated, [req, res, qb]); + } }; relArray.push(relObj); } else { @@ -470,4 +472,4 @@ kalamata.expose = function(model, _opts_) { return kalamata; -}; \ No newline at end of file +}; From 2b44e2643554195b5567a1b259b1dfd06efc27fe Mon Sep 17 00:00:00 2001 From: Marine Date: Thu, 4 Jul 2019 17:07:27 +0100 Subject: [PATCH 02/19] Add pagination in get for collections --- src/index.js | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index ebfdf11..a8d836c 100644 --- a/src/index.js +++ b/src/index.js @@ -72,7 +72,7 @@ kalamata.expose = function(model, _opts_) { function configureEndpoints() { - app.get(options.apiRoot + opts.endpointName, function(req, res, next) { + app.get(options.apiRoot + opts.endpointName, async function(req, res, next) { var mod; if(req.query.where) { var w; @@ -89,6 +89,41 @@ kalamata.expose = function(model, _opts_) { var beforeResult = runHooks(beforeHooks.getCollection, [req, res, mod]); if(res.headersSent) return; + // If we send page or page_size, 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); + + const { total_items, total_pages } = await mod.count('id').then(total_items => ({total_items, total_pages: Math.ceil(total_items/page_size_number)})) + + // If the page number is greater than the number of pages, we return an empty array + if (page_number > total_pages) { + sendResponse(res, []); // Can I do this directly? + return + // If it is the last page, we return only the last elements of the request + } else if (page_number === total_pages) { + const left_items = total_items - (page_number-1)*page_size_number; + + mod.orderBy('id', 'DESC').query(qb => qb.limit(left_items).offset(page_number-1)); // Remove orderBy? + // otherwise, return the elements of the page requested + } else { + mod.orderBy('id', 'DESC').query(qb => qb.limit(page_size_number).offset(page_number-1)); // Remove orderBy? + } + + // 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)) { + res.header('x-prev', `${req.route.path}?page=${page_number - 1}&page_size=${page_size_number}`); + } + if (!(page_number === total_pages)) { + res.header('x-next', `${req.route.path}?page=${page_number + 1}&page_size=${page_size_number}`); + } + res.header('x-total-pages', total_pages); + res.header('x-total-items', total_items); + } + var promise = beforeResult.promise || mod.fetchAll(getFetchParams(req, res)); promise.then(function(collection) { var afterResult = runHooks(afterHooks.getCollection, [req, res, collection]); From 373b2405f461f73930bc9b4fdc344cfa6949a5b2 Mon Sep 17 00:00:00 2001 From: Marine Date: Thu, 4 Jul 2019 17:11:14 +0100 Subject: [PATCH 03/19] Add package-lock to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 4ad1fce6e17d73ab300488efe77fd0da1694794f Mon Sep 17 00:00:00 2001 From: Marine Date: Fri, 5 Jul 2019 14:28:56 +0100 Subject: [PATCH 04/19] Add query.load in links --- src/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index a8d836c..a50bb23 100644 --- a/src/index.js +++ b/src/index.js @@ -115,10 +115,10 @@ kalamata.expose = function(model, _opts_) { // 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)) { - res.header('x-prev', `${req.route.path}?page=${page_number - 1}&page_size=${page_size_number}`); + res.header('x-prev', `${req.route.path}?page=${page_number - 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}`); } if (!(page_number === total_pages)) { - res.header('x-next', `${req.route.path}?page=${page_number + 1}&page_size=${page_size_number}`); + res.header('x-next', `${req.route.path}?page=${page_number + 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}`); } res.header('x-total-pages', total_pages); res.header('x-total-items', total_items); From cd2b6e7c0ad2ace2d7637ab1ca96fdb8409198ab Mon Sep 17 00:00:00 2001 From: Marine Date: Mon, 8 Jul 2019 12:23:03 +0100 Subject: [PATCH 05/19] Remove useless async/await to use only promises --- src/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index a50bb23..37fd469 100644 --- a/src/index.js +++ b/src/index.js @@ -72,7 +72,7 @@ kalamata.expose = function(model, _opts_) { function configureEndpoints() { - app.get(options.apiRoot + opts.endpointName, async function(req, res, next) { + app.get(options.apiRoot + opts.endpointName, function(req, res, next) { var mod; if(req.query.where) { var w; @@ -96,7 +96,7 @@ kalamata.expose = function(model, _opts_) { const page_number = parseInt(page, 10); const page_size_number = parseInt(page_size, 10); - const { total_items, total_pages } = await mod.count('id').then(total_items => ({total_items, total_pages: Math.ceil(total_items/page_size_number)})) + const { total_items, total_pages } = mod.count('id').then(total_items => ({total_items, total_pages: Math.ceil(total_items/page_size_number)})) // If the page number is greater than the number of pages, we return an empty array if (page_number > total_pages) { From b3d4c233f4bf8f86694e39d24c6fbf00bf7bc11b Mon Sep 17 00:00:00 2001 From: Marine Date: Mon, 8 Jul 2019 12:24:03 +0100 Subject: [PATCH 06/19] Split objects to improve dev reading experience --- src/index.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 37fd469..52e8c98 100644 --- a/src/index.js +++ b/src/index.js @@ -96,7 +96,10 @@ kalamata.expose = function(model, _opts_) { const page_number = parseInt(page, 10); const page_size_number = parseInt(page_size, 10); - const { total_items, total_pages } = mod.count('id').then(total_items => ({total_items, total_pages: Math.ceil(total_items/page_size_number)})) + const { total_items, total_pages } = mod.count('id').then(total_items => ({ + total_items, + total_pages: Math.ceil(total_items/page_size_number) + })) // If the page number is greater than the number of pages, we return an empty array if (page_number > total_pages) { @@ -104,7 +107,7 @@ kalamata.expose = function(model, _opts_) { return // If it is the last page, we return only the last elements of the request } else if (page_number === total_pages) { - const left_items = total_items - (page_number-1)*page_size_number; + const left_items = total_items - (page_number-1) * page_size_number; mod.orderBy('id', 'DESC').query(qb => qb.limit(left_items).offset(page_number-1)); // Remove orderBy? // otherwise, return the elements of the page requested @@ -115,7 +118,10 @@ kalamata.expose = function(model, _opts_) { // 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)) { - res.header('x-prev', `${req.route.path}?page=${page_number - 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}`); + res.header( + 'x-prev', + `${req.route.path}?page=${page_number - 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}` + ); } if (!(page_number === total_pages)) { res.header('x-next', `${req.route.path}?page=${page_number + 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}`); From 1094a106ae5e7d67f4303a44edde7026e089e0f7 Mon Sep 17 00:00:00 2001 From: Marine Date: Mon, 8 Jul 2019 12:24:11 +0100 Subject: [PATCH 07/19] Remove comments --- src/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 52e8c98..fabd1c4 100644 --- a/src/index.js +++ b/src/index.js @@ -103,16 +103,16 @@ kalamata.expose = function(model, _opts_) { // If the page number is greater than the number of pages, we return an empty array if (page_number > total_pages) { - sendResponse(res, []); // Can I do this directly? + sendResponse(res, []); return // If it is the last page, we return only the last elements of the request } else if (page_number === total_pages) { const left_items = total_items - (page_number-1) * page_size_number; - mod.orderBy('id', 'DESC').query(qb => qb.limit(left_items).offset(page_number-1)); // Remove orderBy? + mod.orderBy('id', 'DESC').query(qb => qb.limit(left_items).offset(page_number-1)); // otherwise, return the elements of the page requested } else { - mod.orderBy('id', 'DESC').query(qb => qb.limit(page_size_number).offset(page_number-1)); // Remove orderBy? + mod.orderBy('id', 'DESC').query(qb => qb.limit(page_size_number).offset(page_number-1)); } // Add headers in res with links to previous and next pages From 3920335cdcc9bd63e55af486608339b7404c946a Mon Sep 17 00:00:00 2001 From: Marine Date: Mon, 8 Jul 2019 14:56:20 +0100 Subject: [PATCH 08/19] Add promises to get totalPages and pagination --- src/index.js | 98 ++++++++++++++++++++++++++++------------------------ 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/src/index.js b/src/index.js index fabd1c4..6cd5484 100644 --- a/src/index.js +++ b/src/index.js @@ -89,54 +89,60 @@ kalamata.expose = function(model, _opts_) { var beforeResult = runHooks(beforeHooks.getCollection, [req, res, mod]); if(res.headersSent) return; - // If we send page or page_size, 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); - - const { total_items, total_pages } = mod.count('id').then(total_items => ({ - total_items, - total_pages: Math.ceil(total_items/page_size_number) - })) - - // If the page number is greater than the number of pages, we return an empty array - if (page_number > total_pages) { - sendResponse(res, []); - return - // If it is the last page, we return only the last elements of the request - } else if (page_number === total_pages) { - const left_items = total_items - (page_number-1) * page_size_number; - - mod.orderBy('id', 'DESC').query(qb => qb.limit(left_items).offset(page_number-1)); - // otherwise, return the elements of the page requested + var pagePromise = new Promise((resolve, reject) => { + // If we send page or page_size, 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); + + new Promise((resolve, reject) => resolve(mod.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 + if (page_number > total_pages) { + sendResponse(res, []); + return + // If it is the last page, we return only the last elements of the request + } else if (page_number === total_pages) { + const remaining_items = total_items - (page_number - 1) * page_size_number; + + mod.orderBy('id', 'DESC').query(qb => qb.limit(remaining_items).offset(page_number - 1)); + // otherwise, return the elements of the page requested + } else { + mod.orderBy('id', 'DESC').query(qb => qb.limit(page_size_number).offset(page_number - 1)); + } + + // 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)) { + res.header( + 'x-prev', + `${req.route.path}?page=${page_number - 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}` + ); + } + if (!(page_number === total_pages)) { + res.header('x-next', `${req.route.path}?page=${page_number + 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}`); + } + res.header('x-total-pages', total_pages); + res.header('x-total-items', total_items); + }).then(() => resolve()).catch(error => console.warn(error)) } else { - mod.orderBy('id', 'DESC').query(qb => qb.limit(page_size_number).offset(page_number-1)); - } - - // 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)) { - res.header( - 'x-prev', - `${req.route.path}?page=${page_number - 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}` - ); - } - if (!(page_number === total_pages)) { - res.header('x-next', `${req.route.path}?page=${page_number + 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}`); + resolve(); } - res.header('x-total-pages', total_pages); - res.header('x-total-items', total_items); - } - - 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); + }) + + pagePromise.then(() => { + 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', From de6a18c092ec4aff646e003fb1348421037e6b5e Mon Sep 17 00:00:00 2001 From: Marine Date: Mon, 8 Jul 2019 16:19:16 +0100 Subject: [PATCH 09/19] Offset is the number of rows that we skip... --- src/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 6cd5484..a346a21 100644 --- a/src/index.js +++ b/src/index.js @@ -109,10 +109,10 @@ kalamata.expose = function(model, _opts_) { } else if (page_number === total_pages) { const remaining_items = total_items - (page_number - 1) * page_size_number; - mod.orderBy('id', 'DESC').query(qb => qb.limit(remaining_items).offset(page_number - 1)); + 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.orderBy('id', 'DESC').query(qb => qb.limit(page_size_number).offset(page_number - 1)); + 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 From 66fa5979ed30b7a6b0eb2861e6e9f03e7176123c Mon Sep 17 00:00:00 2001 From: Marine Date: Mon, 8 Jul 2019 17:36:47 +0100 Subject: [PATCH 10/19] Improve code by using promise chaining --- src/index.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/index.js b/src/index.js index a346a21..cb7b07e 100644 --- a/src/index.js +++ b/src/index.js @@ -86,18 +86,14 @@ kalamata.expose = function(model, _opts_) { mod = new model(); } - var beforeResult = runHooks(beforeHooks.getCollection, [req, res, mod]); - if(res.headersSent) return; - - var pagePromise = new Promise((resolve, reject) => { - // If we send page or page_size, we want to limit the query + return Promise.resolve().then(() => { 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); - new Promise((resolve, reject) => resolve(mod.count('id'))).then(total_items => ({ + return mod.count('id').then(total_items => ({ total_items, total_pages: Math.ceil(total_items/page_size_number) })).then(({total_items, total_pages}) => { @@ -128,21 +124,27 @@ kalamata.expose = function(model, _opts_) { } res.header('x-total-pages', total_pages); res.header('x-total-items', total_items); - }).then(() => resolve()).catch(error => console.warn(error)) - } else { - resolve(); + }) } - }) + }).then(() => { + if(res.headersSent) return; + + // before hook + var beforeResult = runHooks(beforeHooks.getCollection, [req, res, mod]); + + return beforeResult.promise || mod.fetchAll(getFetchParams(req, res)); + }).then(collection => { + if(res.headersSent) return; pagePromise.then(() => { 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) { + }).then(collection => { + if(res.headersSent) return; sendResponse(res, collection.toJSON()); }).catch(next); - }) }); app.get(options.apiRoot + opts.endpointName + '/:identifier', From a35ea9157b4347ccfbe095ed4341d06543fc1809 Mon Sep 17 00:00:00 2001 From: Marine Date: Mon, 8 Jul 2019 17:36:52 +0100 Subject: [PATCH 11/19] Add comments --- src/index.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/index.js b/src/index.js index cb7b07e..90609dd 100644 --- a/src/index.js +++ b/src/index.js @@ -87,21 +87,26 @@ kalamata.expose = function(model, _opts_) { } return Promise.resolve().then(() => { - if(req.query.page || req.query.page_size) { + // 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.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 + 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 } else if (page_number === total_pages) { const remaining_items = total_items - (page_number - 1) * page_size_number; @@ -125,7 +130,8 @@ kalamata.expose = function(model, _opts_) { res.header('x-total-pages', total_pages); res.header('x-total-items', total_items); }) - } + } + }).then(() => { if(res.headersSent) return; @@ -136,15 +142,16 @@ kalamata.expose = function(model, _opts_) { }).then(collection => { if(res.headersSent) return; - pagePromise.then(() => { - 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; + // after hook + var afterResult = runHooks(afterHooks.getCollection, [req, res, collection]); + + return afterResult.promise || collection; }).then(collection => { if(res.headersSent) return; - sendResponse(res, collection.toJSON()); - }).catch(next); + + // send final response + sendResponse(res, collection.toJSON()); + }).catch(next); }); app.get(options.apiRoot + opts.endpointName + '/:identifier', From cc468a601d571bc7b0cea0d45f01a54dca09d33e Mon Sep 17 00:00:00 2001 From: Marine Date: Mon, 8 Jul 2019 18:03:32 +0100 Subject: [PATCH 12/19] Add eslint --- .eslintrc.js | 52 +++++++++++++++++++++++++++++++++++++++++++ .vscode/settings.json | 3 +++ package.json | 3 +++ 3 files changed, 58 insertions(+) create mode 100644 .eslintrc.js create mode 100644 .vscode/settings.json 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/.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..a1f53d9 100644 --- a/package.json +++ b/package.json @@ -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", From cde8831695f19f1ea7d199ca7bd42227c4a88b82 Mon Sep 17 00:00:00 2001 From: Marine Date: Mon, 8 Jul 2019 18:03:52 +0100 Subject: [PATCH 13/19] Eslint index.js --- src/index.js | 1047 +++++++++++++++++++++++++------------------------- 1 file changed, 519 insertions(+), 528 deletions(-) diff --git a/src/index.js b/src/index.js index 90609dd..61f50d1 100644 --- a/src/index.js +++ b/src/index.js @@ -1,531 +1,522 @@ -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'); + +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_; -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(); - } - - 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.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 - } else if (page_number === total_pages) { - const remaining_items = total_items - (page_number - 1) * page_size_number; - - 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.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)) { - res.header( - 'x-prev', - `${req.route.path}?page=${page_number - 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}` - ); - } - if (!(page_number === total_pages)) { - res.header('x-next', `${req.route.path}?page=${page_number + 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load: ''}`); - } - res.header('x-total-pages', total_pages); - res.header('x-total-items', total_items); - }) - } - - }).then(() => { - if(res.headersSent) return; - - // before hook - var beforeResult = runHooks(beforeHooks.getCollection, [req, res, mod]); - - return beforeResult.promise || mod.fetchAll(getFetchParams(req, res)); - }).then(collection => { - if(res.headersSent) return; - - // after hook - var 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', - 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) { - 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; + 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_) { + 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) => { + let mod; + if (req.query.where) { + let 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(); + } + + 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.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.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.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)) { + res.header( + 'x-prev', + `${req.route.path}?page=${page_number - 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load : ''}`, + ); + } + if (!(page_number === total_pages)) { + res.header('x-next', `${req.route.path}?page=${page_number + 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load : ''}`); + } + 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; }; From 5ac22eafc172f09af4bea9c950b6050e649897c7 Mon Sep 17 00:00:00 2001 From: Freeman Date: Thu, 11 Jul 2019 11:13:56 +0100 Subject: [PATCH 14/19] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a1f53d9..7e4db65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kalamata", - "version": "0.1.4", + "version": "0.1.5", "description": "Extensible REST API for Express + Bookshelf.js", "homepage": "https://github.com/mikec/kalamata", "bugs": "https://github.com/mikec/kalamata/issues", From 05f5605eff607214fbbc8b298fecfa2778aa2a74 Mon Sep 17 00:00:00 2001 From: Nabil Freeman Date: Wed, 17 Jul 2019 11:22:19 +0100 Subject: [PATCH 15/19] when counting the number of pages, clone the model so we don't accidentally discard our where filtering --- src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 61f50d1..c3f0b24 100644 --- a/src/index.js +++ b/src/index.js @@ -93,7 +93,7 @@ kalamata.expose = function (model, _opts_) { const page_size_number = parseInt(page_size, 10); // Get the number of pages and the number of items - return mod.count('id').then(total_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 }) => { From 981cef2560e3e1d343cf4b1636058dc56154c81d Mon Sep 17 00:00:00 2001 From: Nabil Freeman Date: Wed, 17 Jul 2019 11:22:43 +0100 Subject: [PATCH 16/19] chain orderBy, limit, offset onto the original model --- src/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index c3f0b24..3f1487f 100644 --- a/src/index.js +++ b/src/index.js @@ -108,10 +108,10 @@ kalamata.expose = function (model, _opts_) { } if (page_number === total_pages) { const remaining_items = total_items - (page_number - 1) * page_size_number; - mod.orderBy('id', 'DESC').query(qb => qb.limit(remaining_items).offset((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.orderBy('id', 'DESC').query(qb => qb.limit(page_size_number).offset((page_number - 1) * page_size_number)); + 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 From c8c8f7f9fd612e890411748cb9f4e5a456606e6e Mon Sep 17 00:00:00 2001 From: Nabil Freeman Date: Wed, 17 Jul 2019 11:23:26 +0100 Subject: [PATCH 17/19] By hardcoding the structure of the query we excluded where. Also, this is bad practice because load and where are both optional so we should only put them in the URL if they are actually supplied. --- src/index.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 3f1487f..ca45dfa 100644 --- a/src/index.js +++ b/src/index.js @@ -117,13 +117,28 @@ kalamata.expose = function (model, _opts_) { // 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}?page=${page_number - 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load : ''}`, + `${req.route.path}?${Qs.stringify(prev_query)}`, ); } if (!(page_number === total_pages)) { - res.header('x-next', `${req.route.path}?page=${page_number + 1}&page_size=${page_size_number}&load=${req.query.load ? req.query.load : ''}`); + 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); From 389090ffe74d95d438d975f87b5da7855589fdfc Mon Sep 17 00:00:00 2001 From: Nabil Freeman Date: Wed, 17 Jul 2019 11:23:53 +0100 Subject: [PATCH 18/19] initialize the model outside of the where query check, I think this is cleaner because it's clear that where is one of many optional parameters. --- src/index.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/index.js b/src/index.js index ca45dfa..15043e1 100644 --- a/src/index.js +++ b/src/index.js @@ -71,17 +71,23 @@ kalamata.expose = function (model, _opts_) { function configureEndpoints() { app.get(options.apiRoot + opts.endpointName, (req, res, next) => { - let mod; + // initialize our DB request. + let mod = new model(); + if (req.query.where) { - let w; + let where; + + // the query string must be formatted as json. try { - w = parseJSON(req.query.where); + where = 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(); + + // if the "where" was successfully parsed, we can chain it on to the request. + if (where) { + mod = mod.where(where); + } } return Promise.resolve().then(() => { From ebe523a2d06c6f6d5509e4d19629175c2ce3aea6 Mon Sep 17 00:00:00 2001 From: Nabil Freeman Date: Wed, 17 Jul 2019 11:24:53 +0100 Subject: [PATCH 19/19] Add qs to package.json and bump version --- package.json | 5 +++-- src/index.js | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7e4db65..047c2d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kalamata", - "version": "0.1.5", + "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", @@ -32,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 15043e1..c615b7b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,6 @@ const bodyParser = require('body-parser'); const Promise = require('bluebird'); +const Qs = require('qs'); let app; let options;