diff --git a/lib/routes/_lists.js b/lib/routes/_lists.js index cc397bfa..52c12d3d 100644 --- a/lib/routes/_lists.js +++ b/lib/routes/_lists.js @@ -2,6 +2,7 @@ const _ = require('lodash'), responses = require('../responses'), + controller = require('../services/lists'), { withAuthLevel, authLevels } = require('amphora-auth'); /** @@ -17,13 +18,24 @@ function onlyJSONLists(req, res, next) { } } +/** + * Modify a list, return JSON + * @param {Object} req + * @param {Object} res + */ +function patchList(req, res) { + responses.expectJSON(() => controller.patchList(req.uri, req.body, res.locals), res); +} + function routes(router) { router.use(responses.varyWithoutExtension({varyBy: ['Accept']})); router.all('*', responses.acceptJSONOnly); router.all('/', responses.methodNotAllowed({allow: ['get']})); router.get('/', responses.list()); - router.all('/:name', responses.methodNotAllowed({allow: ['get', 'put']})); + router.all('/:name', responses.methodNotAllowed({allow: ['get', 'put', 'patch']})); router.get('/:name', responses.getRouteFromDB); + router.patch('/:name', withAuthLevel(authLevels.WRITE)); + router.patch('/:name', patchList); router.use('/:name', onlyJSONLists); router.put('/:name', withAuthLevel(authLevels.WRITE)); router.put('/:name', responses.putRouteFromDB); @@ -32,3 +44,4 @@ function routes(router) { module.exports = routes; module.exports.onlyJSONLists = onlyJSONLists; +module.exports.patchList = patchList; diff --git a/lib/routes/_lists.test.js b/lib/routes/_lists.test.js index d67995f2..f53e6c2d 100644 --- a/lib/routes/_lists.test.js +++ b/lib/routes/_lists.test.js @@ -4,6 +4,7 @@ const _ = require('lodash'), filename = __filename.split('/').pop().split('.').shift(), lib = require('./' + filename), responses = require('../responses'), + controller = require('../services/lists'), sinon = require('sinon'), expect = require('chai').expect; @@ -12,12 +13,24 @@ describe(_.startCase(filename), function () { beforeEach(function () { sandbox = sinon.sandbox.create(); + + sandbox.stub(controller, 'patchList'); }); afterEach(function () { sandbox.restore(); }); + describe('patchList', function () { + const fn = lib[this.title]; + + it('calls expectjson and lists.patchList', function () { + fn({}, { json: item => item }); + + expect(controller.patchList.called).to.equal(true); + }); + }); + describe('onlyJSONLists', function () { const fn = lib[this.title]; diff --git a/lib/services/lists.js b/lib/services/lists.js new file mode 100644 index 00000000..46899197 --- /dev/null +++ b/lib/services/lists.js @@ -0,0 +1,60 @@ +'use strict'; + +const _isEqual = require('lodash/isEqual'); + +let db = require('./db'); + +/** + * Appends an entry to a list and returns the updated list. + * @param {Array} list + * @param {Array} data + * @returns {Array} + */ +function addToList(list, data) { + return list.concat(data); +} + +/** + * Removes an entry from a list and returns the updated list. + * @param {Array} list + * @param {Array} data + * @returns {Array} + */ +function removeFromList(list, data) { + for (const deletion of data) { + list = list.filter(entry => !_isEqual(entry, deletion)); + } + + return list; +} + +/** + * Add or Remove an item from a list + * @param {string} uri + * @param {Array} data + * @returns {Promise} + */ +function patchList(uri, data) { + if (!Array.isArray(data.add) && !Array.isArray(data.remove)) { + throw new Error('Bad Request. List PATCH requires `add` or `remove` to be an array.'); + } + + return db.get(uri).then(list => { + if (Array.isArray(data.add)) { + list = addToList(list, data.add); + } + + if (Array.isArray(data.remove)) { + list = removeFromList(list, data.remove); + } + + // db.put wraps result in an object `{ _value: list }`, return list only + // hopefully db.put never does anything to the data bc we're just returning list + return db.put(uri, list).then(() => list); + }); +} + +module.exports.patchList = patchList; + +// For testing +module.exports.setDb = mock => db = mock; diff --git a/lib/services/lists.test.js b/lib/services/lists.test.js new file mode 100644 index 00000000..98989f71 --- /dev/null +++ b/lib/services/lists.test.js @@ -0,0 +1,53 @@ +'use strict'; + +const _ = require('lodash'), + filename = __filename.split('/').pop().split('.').shift(), + lib = require('./' + filename), + sinon = require('sinon'), + expect = require('chai').expect, + storage = require('../../test/fixtures/mocks/storage'); + +describe(_.startCase(filename), function () { + let sandbox, db; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + + db = storage(); + lib.setDb(db); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('patchList', function () { + const fn = lib.patchList; + + it('will throw if bad request body', function () { + try { + fn('domain.com/_lists/test', {}); + } catch (e) { + expect(e.message).to.eql('Bad Request. List PATCH requires `add` or `remove` to be an array.'); + } + }); + + it('adds to existing lists if has add property', function () { + db.get.resolves([]); + db.put.callsFake((uri, list) => Promise.resolve({ _value: list })); + + return fn('domain.com/_lists/test', { add: [ 'hello' ] }).then(list => { + expect(list).to.eql([ 'hello' ]); + }); + }); + + it('removes from existing lists if has remove property', function () { + db.get.resolves([ 'hello' ]); + db.put.callsFake((uri, list) => Promise.resolve({ _value: list })); + + return fn('domain.com/_lists/test', { remove: [ 'hello' ] }).then(list => { + expect(list).to.eql([]); + }); + }); + }); +}); diff --git a/test/api/_lists/patch.js b/test/api/_lists/patch.js new file mode 100644 index 00000000..648c5c5d --- /dev/null +++ b/test/api/_lists/patch.js @@ -0,0 +1,38 @@ +'use strict'; + +const _ = require('lodash'), + apiAccepts = require('../../fixtures/api-accepts'), + endpointName = _.startCase(__dirname.split('/').pop()), + filename = _.startCase(__filename.split('/').pop().split('.')[0]), + sinon = require('sinon'); + +describe(endpointName, function () { + describe(filename, function () { + let sandbox, + hostname = 'localhost.example.com', + acceptsJsonBody = apiAccepts.acceptsJsonBody(_.camelCase(filename)), + start = ['item1', 'item2'], + data = { + add: ['item3', 'item4'], + remove: ['item1'] + }, + end = ['item2', 'item3', 'item4']; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: {'/_lists/valid': start} }); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('/_lists/:name', function () { + const path = this.title; + + // overrides existing data + acceptsJsonBody(path, {name: 'valid'}, data, 200, end); + acceptsJsonBody(path, {name: 'missing'}, data, 404); + }); + }); +});