diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 06ba7afd75..faa7ddc984 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -90,5 +90,5 @@ "noAdminAccess": "You don't have admin access.", "pageMustBeNumber": "req.query.page must be a number", "missingUnsubscriptionCode": "Missing unsubscription code.", - "userNotFound": "User Not Found" + "userNotFound": "User not Found" } diff --git a/test/api/v3/integration/meta/models/GET-model_paths.test.js b/test/api/v3/integration/meta/models/GET-model_paths.test.js new file mode 100644 index 0000000000..f878d391df --- /dev/null +++ b/test/api/v3/integration/meta/models/GET-model_paths.test.js @@ -0,0 +1,30 @@ +import { + generateUser, + translate as t, +} from '../../../../../helpers/api-integration/v3'; + +describe('GET /meta/models/:model/paths', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + it('returns an error when model is not accessible or doesn\'t exists', async () => { + await expect(user.get('/meta/models/1234/paths')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + let models = ['habit', 'daily', 'todo', 'reward', 'user', 'tag', 'challenge', 'group']; + models.forEach(model => { + it(`returns the model paths for ${model}`, async () => { + let res = await user.get(`/meta/models/${model}/paths`); + + expect(res._id).to.equal('String'); + expect(res).to.not.have.keys('__v'); + }); + }); +}); diff --git a/website/src/controllers/api-v3/email.js b/website/src/controllers/api-v3/email.js index b3af91b340..4f642d02d6 100644 --- a/website/src/controllers/api-v3/email.js +++ b/website/src/controllers/api-v3/email.js @@ -8,7 +8,7 @@ import { let api = {}; /** - * @api {post} /unsubscribe Unsubscribe an email or user from email notifications + * @api {post} /email/unsubscribe Unsubscribe an email or user from email notifications * @apiVersion 3.0.0 * @apiName UnsubscribeEmail * @apiGroup Unsubscribe diff --git a/website/src/controllers/api-v3/meta/modelsPaths.js b/website/src/controllers/api-v3/meta/modelsPaths.js new file mode 100644 index 0000000000..895bc5aa8d --- /dev/null +++ b/website/src/controllers/api-v3/meta/modelsPaths.js @@ -0,0 +1,39 @@ +import mongoose from 'mongoose'; + +let api = {}; + +let tasksModels = ['habit', 'daily', 'todo', 'reward']; +let allModels = ['user', 'tag', 'challenge', 'group'].concat(tasksModels); + +/** + * @api {get} /meta/models/:model/paths Get all paths for the specified model. Doesn't require authentication + * @apiVersion 3.0.0 + * @apiName GetUserModelPaths + * @apiGroup Meta + * + * @apiParam {string="user","group","challenge","tag","habit","daily","todo","reward"} model The name of the model + * + * @apiSuccess {object} paths A key-value object made of fieldPath: fieldType (like {'field.nested': Boolean}) + */ +api.getModelPaths = { + method: 'GET', + url: '/meta/models/:model/paths', + async handler (req, res) { + req.checkParams('model', res.t('modelNotFound')).notEmpty().isIn(allModels); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let model = req.params.model; + // tasks models are lowercase, the others have the first letter uppercase (User, Group) + if (tasksModels.indexOf(model) === -1) { + model = model.charAt(0).toUpperCase() + model.slice(1); + } + + model = mongoose.model(model); + + res.respond(200, model.getModelPaths()); + }, +}; + +export default api; diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index c0331bf924..90b2e0c142 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -1,6 +1,7 @@ import { uuid } from '../../../../common'; import validator from 'validator'; import objectPath from 'object-path'; // TODO use lodash's unset once v4 is out +import _ from 'lodash'; export default function baseModel (schema, options = {}) { schema.add({ @@ -45,8 +46,9 @@ export default function baseModel (schema, options = {}) { return options.sanitizeTransform ? options.sanitizeTransform(objToSanitize) : objToSanitize; }; - if (!schema.options.toJSON) schema.options.toJSON = {}; if (Array.isArray(options.private)) privateFields.push(...options.private); + + if (!schema.options.toJSON) schema.options.toJSON = {}; schema.options.toJSON.transform = function transformToObject (doc, plainObj) { privateFields.forEach((fieldPath) => { objectPath.del(plainObj, fieldPath); @@ -55,4 +57,14 @@ export default function baseModel (schema, options = {}) { // Allow an additional toJSON transform function to be used return options.toJSONTransform ? options.toJSONTransform(plainObj) : plainObj; }; + + schema.statics.getModelPaths = function getModelPaths () { + return _.reduce(this.schema.paths, (result, field, path) => { + if (privateFields.indexOf(path) === -1) { + result[path] = field.instance || 'Boolean'; + } + + return result; + }, {}); + }; } diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index bfe2ab93e7..05786a1287 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -10,19 +10,25 @@ let router = express.Router(); // eslint-disable-line babel/new-cap // It takes the async function, execute it and pass any error to next (args[2]) let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]); -fs - .readdirSync(CONTROLLERS_PATH) - .filter(fileName => fileName.match(/\.js$/)) - .filter(fileName => fs.statSync(CONTROLLERS_PATH + fileName).isFile()) - .forEach((fileName) => { - let controller = require(CONTROLLERS_PATH + fileName); // eslint-disable-line global-require +function walkControllers (filePath) { + fs + .readdirSync(filePath) + .forEach(fileName => { + if (!fs.statSync(filePath + fileName).isFile()) { + walkControllers(`${filePath}${fileName}/`); + } else if (fileName.match(/\.js$/)) { + let controller = require(filePath + fileName); // eslint-disable-line global-require - _.each(controller, (action) => { - let {method, url, middlewares = [], handler} = action; + _.each(controller, (action) => { + let {method, url, middlewares = [], handler} = action; - method = method.toLowerCase(); - router[method](url, ...middlewares, _wrapAsyncFn(handler)); + method = method.toLowerCase(); + router[method](url, ...middlewares, _wrapAsyncFn(handler)); + }); + } }); - }); +} + +walkControllers(CONTROLLERS_PATH); export default router;