diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25c8fdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +package-lock.json \ No newline at end of file diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..8f939e5 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,7 @@ +v1.0.0 +- Project structure init +- Create an endpoint to list all the customers +- Create an endpoint to get a specific user +- Create an endpoint to save the user +- Create an endpoint to update a given user +- Create an endpoint to delete a given user \ No newline at end of file diff --git a/README.md b/README.md index c09c2d6..14da3ee 100644 --- a/README.md +++ b/README.md @@ -1 +1,14 @@ -# codingChallenge \ No newline at end of file +# README # + +This README would normally document whatever steps are necessary to get your application up and running. + +### What is this repository for? ### + +* This is the backend of an app for a contest of Bob.io +* 0.0.1 + +### How do I get set up? ### + +* How to run tests (TODO) + +* To run the backend, simply use node path/of/your/folder/backend/server.js \ No newline at end of file diff --git a/RELEASE b/RELEASE new file mode 100644 index 0000000..04f6152 --- /dev/null +++ b/RELEASE @@ -0,0 +1,2 @@ +v1.0.1 +- Add support for pagination \ No newline at end of file diff --git a/components/customer/index.js b/components/customer/index.js new file mode 100644 index 0000000..392d2e1 --- /dev/null +++ b/components/customer/index.js @@ -0,0 +1,67 @@ +const Store = require('./store'); +const Validate = require('./validations'); +const e = require('../../helpers/errors'); + +exports.list = list; +exports.get = get; +exports.post = post; +exports.update = update; +exports.remove = remove; + +/** + * Method that return a list of customers + */ +function list(query) { + return Store.list(query.offset, query.limit).then(customers => { + return { data: customers }; + }); +} + +/** + * Method that get a customer + * @param {String} name The customer name + */ +function get(name) { + return Validate.get(name).then(() => { + return Store.get(name); + }).then(customer => { + return { data: customer }; + }); +} + +/** + * Method that add to the collection a new customer + * @param {Object} data The name of the customer and bags + */ +function post(data) { + return Validate.post(data.name, data.bags).then(() => { + return Store.post(data.name, data.bags); + }).then(customer => { + return { data: customer }; + }); +} + +/** + * Method that update bags or the name of a valid customer + * @param {String} customerId The id of the customer + * @param {Object} data The name of the customer or a new bag amount + */ +function update(customerId, data) { + return Validate.update(customerId, data.name, data.bags).then(() => { + return Store.update(customerId, data.name, data.bags); + }).then(() => { + return { data: true }; + }); +} + +/** + * Method that delete users + * @param {String} customerId The id of the customer + */ +function remove(customerId) { + return Validate.remove(customerId).then(() => { + return Store.remove(customerId); + }).then(() => { + return { data: true }; + }); +} \ No newline at end of file diff --git a/components/customer/model.js b/components/customer/model.js new file mode 100644 index 0000000..e91cc01 --- /dev/null +++ b/components/customer/model.js @@ -0,0 +1,19 @@ +const model = { + name: { + type: 'String', + length: { + min: 3, + max: 200 + }, + trim: true, + required: true, + index: true + }, + bags: { + type: 'Number' + } +}; + +module.exports = { + model: model +}; \ No newline at end of file diff --git a/components/customer/network.js b/components/customer/network.js new file mode 100644 index 0000000..905f0aa --- /dev/null +++ b/components/customer/network.js @@ -0,0 +1,45 @@ +const router = require("express").Router(); +const Response = require('../../network/response'); +const Controller = require('./'); + +router.get('/', (req, res, next) => { + Controller.list(req.query).then(response => { + Response.success(req, res, next, (response.code || 200), response.data); + }).catch(error => { + Response.error(req, res, next, (error.status || 500), error.message); + }); +}); + +router.get('/:name', (req, res, next) => { + Controller.get(req.params.name).then(response => { + Response.success(req, res, next, (response.code || 200), response.data); + }).catch(error => { + Response.error(req, res, next, (error.status || 500), error.message); + }); +}); + +router.post('/', (req, res, next) => { + Controller.post(req.body).then(response => { + Response.success(req, res, next, (response.code || 200), response.data); + }).catch(error => { + Response.error(req, res, next, (error.status || 500), error.message); + }); +}); + +router.put('/:id', (req, res, next) => { + Controller.update(req.params.id, req.body).then(response => { + Response.success(req, res, next, (response.code || 200), response.data); + }).catch(error => { + Response.error(req, res, next, (error.status || 500), error.message); + }); +}); + +router.delete('/:id', (req, res, next) => { + Controller.remove(req.params.id).then(response => { + Response.success(req, res, next, (response.code || 200), response.data); + }).catch(error => { + Response.error(req, res, next, (error.status || 500), error.message); + }); +}); + +module.exports = router; diff --git a/components/customer/store.js b/components/customer/store.js new file mode 100644 index 0000000..7b96c1e --- /dev/null +++ b/components/customer/store.js @@ -0,0 +1,97 @@ +const Store = require('../../store/mongo'); +const e = require('../../helpers/errors'); + +const schema = 'Customer'; + +exports.list = list; +exports.get = get; +exports.post = post; +exports.update = update; +exports.remove = remove; + +/** + * Method that verifies if the customer exist or not + * @param {Object} condition + */ +function exist(condition) { + return Store.query(schema, condition, {}, false).then(response => { + return response ? true : false; + }); +} + +/** + * Method that return a list of customers + */ +function list(offset, limit) { + if (offset && limit) { + const statements = { + select: 'name bags', + skip: parseInt(offset), + limit: parseInt(limit) + + }; + return Store.query(schema, {}, statements, true); + } + + return Store.list(schema, {}); +} + +/** + * Method that return a customer by its name if exist + * @param {String} name The name of the customer + */ +function get(name) { + return exist({ name: name }).then(response => { + if (!response) { + throw e.error('CUSTOMER_NOT_EXIST'); + } + return Store.query(schema, { name: name }, {}, false); + }); +} + +/** + * Method that post an user to the customer collection + * @param {String} name The name of the customer + * @param {Number} bags The amount of bags + */ +function post(name, bags) { + const query = { + name: name, + bags: bags + }; + return Store.post(schema, query); +} + +/** + * Method that update a customer name or bags amount depending of its id + * @param {String} customerId The id of the customer + * @param {String} name The name of the customer + * @param {Number} bags The amount of bags + */ +function update(customerId, name, bags) { + let query = {}; + + if (name) query.name = name; + if (bags) query.bags = bags + + return exist({ id: customerId }).then(response => { + if (!response) { + throw e.error('CUSTOMER_NOT_EXIST'); + } + return Store.upsert(schema, customerId, query, null); + }); +} + +/** + * Method that removes a customer from the collection if exists. + * @param {String} customerId The id of the customer + */ +function remove(customerId) { + const condition = { id: customerId }; + return exist(condition).then(response => { + if (!response) { + throw e.error('CUSTOMER_NOT_EXIST'); + } + return Store.remove(schema, condition, false); + }); +} \ No newline at end of file diff --git a/components/customer/validations.js b/components/customer/validations.js new file mode 100644 index 0000000..5a081f5 --- /dev/null +++ b/components/customer/validations.js @@ -0,0 +1,100 @@ +const Validations = require('../../helpers/validations'); +const e = require('../../helpers/errors'); + +exports.get = get; +exports.post = post; +exports.update = update; +exports.remove = remove; + +/** + * Verify that GET method fit the model requirements + * @param {String} name The name of the customer + */ +function get(name) { + return verifyName(name); +} + +/** + * Verify that POST method fit the model requirements + * @param {String} name The name of the customer + * @param {Number} bags The amount of bags the user has + */ +function post(name, bags) { + return verifyName(name).then(() => { + return verifyBags(bags); + }); +} + +/** + * Verify that PUT method fit the model requirements + * @param {String} id The id of the customer + * @param {String} name The name of the customer + * @param {Number} bags The amount of bags the user has + */ +function update(id, name, bags) { + let promises = []; + + if (name) promises.push(verifyName(name)); + if (bags) promises.push(verifyBags(bags)); + + promises.push(verifyId(id)); + + return Promise.all(promises); +} + +/** + * Verify that DELETE method fit the model requirements + * @param {String} id The id of the customer + */ +function remove(id) { + return verifyId(id); +} + +/** + * Validate that the id of the customer is defined and has a mongoose format + * @param {String} id The id of the customer + */ +function verifyId(id) { + return new Promise((resolve, reject) => { + if (Validations.isUndefined(id)) { + return reject(e.error('CUSTOMER_ID_NOT_DEFINED')); + } else if (!Validations.isMongoose(id)) { + return reject(e.error('CUSTOMER_ID_NOT_DEFINED')); + } + resolve(); + }); +} + +/** + * Validate that the name of the customer has been defined, is string and is between 3 and 200 characters long + * @param {String} name The name of the customer + */ +function verifyName(name) { + return new Promise((resolve, reject) => { + if (Validations.isUndefined(name)) { + return reject(e.error('CUSTOMER_NAME_IS_REQUIRED')); + } else if (!Validations.isString(name)) { + return reject(e.error('CUSTOMER_NAME_IS_NOT_VALID')); + } else if (!Validations.minLength(name, 3)) { + return reject(e.error('CUSTOMER_NAME_IS_SHORTER')); + } else if (!Validations.maxLength(name, 200)) { + return reject(e.error('CUSTOMER_NAME_IS_LONGER')); + } + resolve(); + }); +} + +/** + * Verify that bags is a defined number between 1 and 5 + * @param {Number} bags The amount of bags the user has + */ +function verifyBags(bags) { + return new Promise((resolve, reject) => { + if (Validations.isUndefined(bags)) { + return reject(e.error('CUSTOMER_BAGS_NOT_DEFINED')); + } else if (!Validations.isNumber(bags) || !Validations.isPositiveNumber(bags) || bags <= 0 || bags > 5) { + return reject(e.error('CUSTOMER_BAGS_NOT_VALID')); + } + resolve(); + }); +} \ No newline at end of file diff --git a/config.js b/config.js new file mode 100644 index 0000000..aa8ebd5 --- /dev/null +++ b/config.js @@ -0,0 +1,15 @@ +const config = { + // Server port + server_port: 3000, + + // Server environment + node_env: 'development', + + // Database configuration + mongodb_uri: 'mongodb://localhost:27017/bobcontest', + + // Winston 3rd party library + logLevel: 'info', +}; + +module.exports = config; diff --git a/helpers/errors.js b/helpers/errors.js new file mode 100644 index 0000000..f62db4e --- /dev/null +++ b/helpers/errors.js @@ -0,0 +1,19 @@ +let lang = 'en-EN'; + +function setLang(value) { + if (value) { + lang = value.match(/es-ES/) || value.match(/es/) ? 'es-ES' : 'en-EN'; + } +} + +function error (key, status) { + const langSelected = require('../languages/' + lang); + const err = new Error(langSelected[key]); + err.status = status || 404; + return err; +}; + +module.exports = { + setLang: setLang, + error: error +}; \ No newline at end of file diff --git a/helpers/validations.js b/helpers/validations.js new file mode 100644 index 0000000..296843f --- /dev/null +++ b/helpers/validations.js @@ -0,0 +1,83 @@ +const validator = require('validator'); + +exports.isUndefined = isUndefined; +exports.isEmail = isEmail; +exports.isString = isString; +exports.isNumber = isNumber; +exports.isArray = isArray; +exports.isObject = isObject; +exports.isPositiveNumber = isPositiveNumber; +exports.isMongoose = isMongoose; +exports.isMobilePhone = isMobilePhone; +exports.minLength = minLength; +exports.maxLength = maxLength; + +function isUndefined(value) { + if (typeof value === "undefined") { + return true; + } +}; + +function isEmail(email) { + if (validator.isEmail(email) === true) { + return true; + } +}; + +function isString(string) { + if (typeof string === 'string') { + return true; + } +}; + +function isNumber(number) { + if (!isNaN(number)) { + return true + } +}; + +function isArray(array) { + if (Array.isArray(array) === true) { + return true; + } +}; + +function isObject(object) { + if (typeof object === 'object') { + return true; + } +}; + +function isPositiveNumber(number) { + if (number >= 0) { + return true; + } +}; + +function isMongoose(objectId) { + if (typeof objectId !== 'String') { + objectId = objectId.toString(); + } + if (validator.isMongoId(objectId) === true) { + return true; + } + return false; +}; + +function isMobilePhone(phone) { + if (validator.isMobilePhone(phone, 'es-ES') === true) { + return true; + } +}; + +function minLength(string, minValue) { + if (string.length >= minValue) { + return true; + } +}; + +function maxLength(string, maxValue) { + if (string.length <= maxValue) { + return true + } +}; diff --git a/languages/en-EN.js b/languages/en-EN.js new file mode 100644 index 0000000..db0f1e8 --- /dev/null +++ b/languages/en-EN.js @@ -0,0 +1,23 @@ +let lang = { + INTERNAL_ISSUE: "Problema interno. Contactar con el administrador", + NOT_DEFINED: "is not defined", + NOT_EXPECTED_TYPE: "Some fields are not of the expected type", + NOT_NEGATIVE_NUMBER: "The number can't be negative", + NOT_VALID_PHONE: "The mobile phone is not correct", + STRING_IS_LONG: "Some strings are longer than the normal value", + STRING_IS_SHORT: "Some string are shorter than the normal value", + INVALID_DATE: "The date must be present and not past", + DATABASE_ERROR: "We're having technical problems. Try again later.", + + CUSTOMER_ID_NOT_DEFINED: "The customer id must be defined", + CUSTOMER_NAME_IS_REQUIRED: "The customer name must be defined", + CUSTOMER_NAME_IS_NOT_VALID: "The customer name is not valid", + CUSTOMER_NOT_EXIST: "The customer doesn't exist", + CUSTOMER_NAME_IS_LONGER: "The name of the customer must be shorter than 200 characters", + CUSTOMER_NAME_IS_SHORTER: "The name of the customer must be longer than 3 characters", + CUSTOMER_BAGS_NOT_DEFINED: "The bags quantity must be defined", + CUSTOMER_BAGS_NOT_VALID: "The amount of bags must be between 1 and 5" + +}; + +module.exports = lang; \ No newline at end of file diff --git a/languages/es-ES.js b/languages/es-ES.js new file mode 100644 index 0000000..9273e4e --- /dev/null +++ b/languages/es-ES.js @@ -0,0 +1,22 @@ +let lang = { + INTERNAL_ISSUE: "Problema interno. Contactar con el administrador.", + NOT_DEFINED: "no está definido.", + NOT_EXPECTED_TYPE: "El campo es incorrecto.", + NOT_NEGATIVE_NUMBER: "El número no puede ser negativo.", + NOT_VALID_PHONE: "El número de teléfono es incorrecto.", + STRING_IS_LONG: "El texto es demasiado largo.", + STRING_IS_SHORT: "El texto es demasiado pequeño.", + INVALID_DATE: "La fecha introducida no puede ser pasada.", + DATABASE_ERROR: "Estamos experimentando problemas técnicos. Inténtalo de nuevo mas tarde.", + + CUSTOMER_ID_NOT_DEFINED: "Debes definir el id del cliente", + CUSTOMER_NAME_IS_REQUIRED: "El nombre del cliente debe ser especificado", + CUSTOMER_NAME_IS_NOT_VALID: "El nombre del cliente no es válido", + CUSTOMER_NOT_EXIST: "El cliente no existe", + CUSTOMER_NAME_IS_LONGER: "El nombre del cliente debe ser inferior a 200 caracteres", + CUSTOMER_NAME_IS_LONGER: "El nombre del cliente debe ser superior a 3 caracteres", + CUSTOMER_BAGS_NOT_DEFINED: "Tienes que definir la cantidad de maletas", + CUSTOMER_BAGS_NOT_VALID: "Las maletas tienen que estar entre 1 y 5" +}; + +module.exports = lang; \ No newline at end of file diff --git a/logger.js b/logger.js new file mode 100644 index 0000000..cd1d5ee --- /dev/null +++ b/logger.js @@ -0,0 +1,25 @@ +const winston = require('winston'); +const Config = require('./config'); +// Logger + +const tsFormat = () => (new Date()).toLocaleTimeString(); +const logLevel = Config.logLevel; + +const logger = new winston.Logger({ + level: logLevel, + transports: [ + // + // - Write to all logs with level `info` and below to `combined.log` + // - Write all logs error (and below) to `error.log`. + // + new (winston.transports.Console)({ + timestamp: tsFormat, + colorize: true, + }), + // new (winston.transports.File)({ filename: 'winston.log' }) + ] +}); +logger.logLevel = logLevel; +console.log("instantiated winston with level ", logger.logLevel); + +module.exports = logger; \ No newline at end of file diff --git a/network/response.js b/network/response.js new file mode 100644 index 0000000..a311381 --- /dev/null +++ b/network/response.js @@ -0,0 +1,20 @@ +const statusMessage = { + '200': 'Done', + '201': 'Created', + '202': 'Accepted', + '204': 'No content', + '400': 'Invalid format', + '401': 'Unauthenticated', + '403': 'Forbidden', + '404': 'Not found', + '409': 'Conflict', // Data already exist in DB + '500': 'Internal error' +}; + +exports.success = function(req, res, next, code, data) { + res.status(code || 200).send(data || statusMessage[code]); +}; + +exports.error = function(req, res, next, code, message) { + res.status(code || 500).send({msg: message || statusMessage[code]}); +}; \ No newline at end of file diff --git a/network/routes.js b/network/routes.js new file mode 100644 index 0000000..683dcec --- /dev/null +++ b/network/routes.js @@ -0,0 +1,8 @@ +const routes = server => { + + const component = function(name) { return '../components/' + name + '/network' }; + + server.use('/customer', require(component('customer'))); +}; + +module.exports = routes; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100755 index 0000000..75ee364 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "Challenge", + "version": "1.0.1", + "description": "Bob.io Challenge", + "main": "server.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node src/server.js", + "start-local": "node server.js", + "dev": "nodemon src/server.js", + "dev-local": "nodemon server.js" + }, + "engines": { + "node": "6.9.5", + "npm": "4.2.0" + }, + "repository": { + "type": "git", + "url": "git+https://DevStarlight@bitbucket.org/devstarligh/codingChallenge.git" + }, + "author": "Jesús Huerta Arrabal", + "license": "ISC", + "bugs": { + "url": "" + }, + "homepage": "", + "keywords": [ + "food", + "restaurant", + "healtyFood" + ], + "dependencies": { + "body-parser": "1.18.1", + "express": "4.16.3", + "morgan": "1.9.0", + "mongoose": "4.13.12", + "cors": "2.8.4", + "bluebird": "3.5.1", + "fs": "0.0.1-security", + "validator": "5.7.0", + "winston": "^2.4.0", + "helmet": "^3.12.0" + }, + "devDependencies": { + "nodemon": "^1.17.2" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..cb106b0 --- /dev/null +++ b/server.js @@ -0,0 +1,32 @@ +const express = require('express'); +const bodyParser = require('body-parser'); +const mongoose = require('mongoose'); +const Promise = require('bluebird'); +const morgan = require('morgan'); +const cors = require('cors'); +const helmet = require('helmet'); +const Config = require('./config'); +const Routes = require('./network/routes'); +const e = require('./helpers/errors'); + +mongoose.Promise = Promise; +mongoose.connect(Config.mongodb_uri, { useMongoClient: true }); +mongoose.connection.on('error', (error) => { throw error; }); + +const server = express(); + +server.use(helmet()); +server.use(cors()); + +server.use(morgan('[:date[iso]] :method :url :remote-user :status :response-time ms :user-agent')); +server.use(bodyParser.urlencoded({ extended: true })); +server.use(bodyParser.json()); + +server.use((req, res, next) => { + e.setLang(req.headers['accept-language']); + next(); +}); + +Routes(server); + +server.listen(Config.server_port); \ No newline at end of file diff --git a/store/mongo.js b/store/mongo.js new file mode 100644 index 0000000..a78c108 --- /dev/null +++ b/store/mongo.js @@ -0,0 +1,165 @@ +/* + Script that connects store layer with mongoose + - Support for declaring new models + - Support for mongoose.type.objectId referenced fields + - Query by id, or any other params, for a single or multiple results. + - Post - Create a new document + - Update by id or any other field + + POWERED BY DEVSTARLIGHT +*/ + +const mongoose = require('mongoose'); +const Promise = require('bluebird'); +const logger = require('../logger'); +const e = require('../helpers/errors'); + +mongoose.Promise = Promise; + +let modelInstances = []; + +exports.list = list; +exports.count = count; +exports.query = query; +exports.post = post; +exports.upsert = upsert; +exports.remove = remove; + +function list(name, query) { + let schema = setupSchema(name); + + return schema.find(query).lean().exec().catch(error => { + logger.error(error.message); + throw e.error('DATABASE_ERROR', 500); + }); +} + +function count(name, condition) { + let schema = setupSchema(name); + + return schema.count(condition).catch(error => { + logger.error(error.message); + throw e.error('DATABASE_ERROR', 500); + }); +} + +function query(name, condition, statements, multi) { + let schema = setupSchema(name); + + let sentence = undefined; + + if (condition && condition.id && multi === false) { + sentence = schema.findById(condition.id); + } else if (multi) { + sentence = schema.find(condition); + } else { + sentence = schema.findOne(condition); + } + + for (const key in statements) { + const statement = statements[key]; + if (key === 'populate') { + setupSchemaFromRecursivePopulate(statement); + } + sentence[key](statement); + } + + return sentence.lean().exec().catch(error => { + logger.error(error.message); + throw e.error('DATABASE_ERROR', 500); + }); +} + +function post(name, query) { + let schema = setupSchema(name); + + return schema.create(query).catch(error => { + logger.error(error.message); + throw e.error('DATABASE_ERROR', 500); + }); +} + +function upsert(name, condition, query, filters) { + let schema = setupSchema(name); + + let sentence = undefined; + + if (typeof condition === 'object') { + sentence = schema.update(condition, query, filters); + } else { + sentence = schema.findByIdAndUpdate(condition, query, filters); + } + + return sentence.lean().exec().then(response => { + if (response.upserted) { + return response.upserted[0]._id; + } + return response ? true : false; + }).catch(error => { + logger.error(error.message); + throw e.error('DATABASE_ERROR', 500); + }); +} + +function remove(name, condition, multi) { + let schema = setupSchema(name); + let sentence = undefined; + + if (condition && condition.id && multi === false) { + sentence = schema.findById(condition.id); + } else if (multi) { + sentence = schema.find(condition); + } else { + sentence = schema.findOne(condition); + } + + return sentence.remove().exec().then(response => { + return response.result.n === 0 ? false : true; + }).catch(error => { + logger.error(error.message); + throw e.error('DATABASE_ERROR', 500); + }); +} + +function getModel(name) { + let model = require('../components/' + lowerCaseFirstLetter(name) + '/model').model; + + for (const key in model) { + if (model[key].ref) { + model[key].type = mongoose.Schema.Types.ObjectId; + } else if (model[key][0] && model[key][0].ref) { + model[key][0].type = mongoose.Schema.Types.ObjectId; + } + } + + return model; +} + +function setupSchema(name) { + if (modelInstances[name]) { + return modelInstances[name]; + } + let model = getModel(name); + let schema = new mongoose.Schema(model, { timestamps: { createdAt: 'created_at' } }); + modelInstances[name] = mongoose.model(name, schema); + return modelInstances[name]; +} + +function setupSchemaFromRecursivePopulate(object) { + for (const element in object) { + if (object[element].path) { + const schema = setupSchema(capitalizeFirstLetter(object[element].path)); + for (const subobject in object[element].populate) { + setupSchemaFromRecursivePopulate([object[element].populate[subobject]]); + } + } + } +} + +function capitalizeFirstLetter(string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +function lowerCaseFirstLetter(string) { + return string.toLowerCase(); +} \ No newline at end of file