чорнетка
У налаштуванні Mongo Atlas ми отримали URL підключення до бази даних. Пропишемо його у нашому файлі .env в проєкті. І при завантаженні середовища посилання на підключення до бази даних буде зберігатися в process.env.MONGO_URL.
MONGO_URL='mongodb+srv://khomiak:password1234@cluster0.z7jjmxa.mongodb.net/khomiak_db'
Для роботи з базою даних MongoDB в проєкті будемо використовувати додатковий пакет mongoose.
npm i mongoose
Проведемо далі факторинг нашого проєкту.
Модель
Створимо папку models, а в ній файл userModel.js. По суті це ніби форма в якій ми описуємо структуру даних, які будемо зберігати.
З бібліотеки mongoose отримуємо Schema. На основі цього класу створюємо нову схему в яку передаємо обʼєкт з описом всіх параметрів.
Ми знаємо, що змінна буде рядок, або число тощо, то можемо просто прописати name: String або year: Number.
Якщо хочемо докладніше описати структуру даних, то описуємо кожен параметр в обʼєкті. Тут type - формат даних, required - що параметр обовʼязковий. unique - вказує що дані в такому полі мають бути унікальні. В даному полі (як на прикладі коду) можна вказати масив, де перший параметр true, а другий - наше кастомне повідомлення у разі помилки.
Оскільки створюємо базу користувачів, то можна розмежувати їх за правами доступу. для цього визначають ролі користувачів. У схемі в параметрі enum вказують масив значень, яких може набувати цей параметр. default відповідно вказує, яке буде значення за замовчанням. У прикладі ці значення винесені в окремий файл const. Також напроти поля password вказано select: false - це означає, що за GET-запитом із результатів буде прибиратися поле пароля, що ми побачимо далі.
const User = model('User', userSchema); - по суті створення моделі, назву якій задаємо як перший параметр (з великої літери), а другий вказується схема моделі.
const { model, Schema } = require('mongoose');
const userRolesEnum = require('../constants/userRolesEnum');
// схема з обʼєктом налаштувань користувача
const userSchema = new Schema(
{
name: {
type: String,
required: true,
},
email: {
type: String,
required: true,
unique: [true, 'Duplicated email..'],
},
password: {
type: String,
required: true,
select: false,
},
year: Number,
role: {
type: String,
// enum: ['admin', 'user', 'moderator'],
// default: 'user'
enum: Object.values(userRolesEnum),
default: userRolesEnum.USER,
},
},
);
const User = model('User', userSchema);
module.exports = User;
Сталі значення зазвичай заведено виносити в окремі файли. (це, наприклад переліки можливих варіантів вибору). Якщо подивимося, то ми їх використовуємо у схемі налаштувань користувача вище. Винесли в окремий файл, бо можемо використовувати й в інших модулях нашого проєкту.
const userRolesEnum = {
ADMIN: 'admin',
MODERATOR: 'moderator',
USER: 'user',
};
module.exports = userRolesEnum;
Рефакторинг Валідації
Тепер трохи рефакторнемо нашу валідацію Joi. Зокрема під нашу схему напишемо валідацію перевірки. Для пароля будемо використовувати патерн із регулярного виразу. У ньому {8,128} означає, що пароль має бути від 8 до 128 символів. Також обовʼязково повинна бути велика, мала літера, число і якийсь спецсимвол.
Розберемо змінену валідацію прописану раніше.
Із нового тут regex - приймає значення і перевіряє на відповідність регулярному виразу вказаному в круглих дужках.
Там де перевірка ролі користувача у полі valid вказуються всі можливі значення.
const Joi = require('joi');
const userRolesEnum = require('../constants/userRolesEnum');
const PASSWD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#\\$%\\^&\\*])(?=.{8,128})/;
exports.createUserDataValidator = (data) =>
Joi.object()
.options({ abortEarly: false })
.keys({
name: Joi.string().min(3).max(30).required(),
year: Joi.number().min(1940).max(2023),
email: Joi.string().email().required(),
password: Joi.string().regex(PASSWD_REGEX).required(),
role: Joi.string().valid(...Object.values(userRolesEnum)),
})
.validate(data);
Валідація у проміжному пз
Дотепер валідацію ми проводили в контролерах, але логічно винести валідацію у проміжне пз (middleware). Спочатку підключаємо нашу функцію валідатора Joi userValidators. Функція буде асинхронна тому ми її огортаємо в catchAsync.
Далі викликаємо функцію валідації Joi. Передаємо в неї всі дані які отримали (req.body). Як відповідь перевірки отримуємо error і value. Якщо є помилка, то валідація не пройдена, генеруємо помилку.
Далі в цьому ж проміжному пз перевіримо чи є такий email в базі. Для цього уже будемо використовувати створену раніше модель, тому її спочатку підключаємо. і за допомогою методу exists (бібліотеки mongoose) перевіряємо чи є вже такий email користувача в базі даних. Якщо є, то генеруємо помилку. Якщо немає, то перевірка пройдена, перезаписуємо провалідовані дані та передаємо виконання коду наступній функції обробки.
const { AppError, catchAsync } = require("../utils");
const { createUserDataValidator } = require("../utils/userValidator");
const User = require("../models/userModels");
exports.checkCreateUserData = catchAsync(async (req, res, next) => {
const { error, value } = userValidators.createUserDataValidator(req.body);
if (error) {
console.log(error);
throw new AppError(400, 'Invalid user data..');
}
const userExists = await User.exists({ email: value.email });
if (userExists) throw new AppError(409, 'User with this email exists..');
req.body = value;
next();
});
Після цього функцію валідації checkCreateUserData потрібно вказати в роутах, як проміжне пз.
const { checkCreateUserData } = require("../middlewares/userMiddlewares");
//code
router.route("/").post(checkCreateUserData, createUser).get(getAllUsers);
Підключення до MongoDB
Щоб мати доступ до бази даних потрібно її під'єднати у головному файлі index.js.
Для цього імпортуємо mongoose і підключаємо перед middleware нашу базу даних. Далі наведено один із прикладів підʼєднання бази даних через метод connect. І оскільки це асинхронна операція, то повертає проміс, тому прописуємо then і catch. Якщо помилка виникає, то виходимо з процесу.
const mongoose = require('mongoose');
//code
// ===========MONGO DB CONNECTOIN=========== //
mongoose
.connect(process.env.MONGO_URL)
.then((con) => {
console.log('Mongo DB successfully connected..');
})
.catch((err) => {
console.log(err);
process.exit(1);
});
Рефакторинг контроллерів для роботи з БД для створення користувача і отримання списку користувачів.
Тепер нам тут не потрібно генерувати id та записувати в файл, тому коментуймо, або видаляємо наступні бібліотеки.
// const uuid = require("uuid").v4;
// const fs = require("fs").promises;
Модель підключаємо в контролерах. Створюють користувача за допомогою метода create. А шукають користувачів за допомогою метода find.
const User = require("../models/userModels");
const { catchAsync, AppError } = require("../utils");
exports.createUser = catchAsync(async (req, res) => {
const newUser = await User.create(req.body);
res.status(201).json({
msg: "User created",
user: newUser,
});
});
exports.getAllUsers = catchAsync(async (req, res) => {
const users = await User.find();
res.status(200).json({ msg: "Success", users });
});
Можна перевірити всі хибні варіанти переданих даних і подивитися які видаються помилки в консолі, а також, що повертає сервер. І врешті зробити POST-запит на сервер з коректними даними.

Після цього перевірити дані, які надійшли в базу даних:

Як бачимо, у відповідь із сервера приходить створений запис із вмістом password, чого не має бути.
Щоб на GET-запит не повертався пароль ми прописали у схемі.

Також для GET-запитів у контролерах можна вказати параметри, які ми хочемо повернути (в прикладі повернеться тільки імʼя і рік):
const users = await User.find().select("name year");
Або параметри, які хочемо прибрати із результату, який повертається (в прикладі повернеться все крім імені (ну і пароля, який не повертається через налаштування схеми)):
const users = await User.find().select("-name");
Якщо ми хочемо примусово витягнути якесь поле, яке в схемі позначено, що не провертається (пароль) то потрібно вказати +.
const users = await User.find().select("+password");
Також може повертатися версія обʼєкта, яку можна прибрати і не повертати
const users = await User.find().select("-__v");
Але при створенні нового юзера у нас все одно у відповідь повертається обʼєкт з паролем. тобто цей select не відпрацьовується на новостворений обʼєкт. Для цього при створенні користувача перед поверненням присвоїмо паролю значення undefind. Тобто приховані дані з res потрібно завжди ЗАБИРАТИ!!
exports.createUser = catchAsync(async (req, res) => {
const newUser = await User.create(req.body);
newUser.password = undefined;
res.status(201).json({
msg: "User created",
user: newUser,
});
});
Опціональні конфіги схеми
У моделі можна іще додати опціонально обʼєкт з конфігами
{
timestamps: true,
versionKey: false,
}
versionKey: false - означає, що нам не буде повертатися ключ версії __v і не треба вказувати, зоб його прибирати, як було розглянуто раніше
timestamps: true - додає в базу даних інформацію, коли обʼєкт створено і коли обʼєкт оновлено. (буває корисно)
Рефакторинг контроллера отримання користувача за id
спочатку перепишемо middleware перевірки id користувача(пам'ятаємо, що в рутах він іде як userId).
За допомогою Types ми можемо перевірити чи валідний сам по собі userId об'єкту _id. Друга перевірка - чи існує такий користувач із таким полем.
const { Types } = require("mongoose");
const { AppError, catchAsync } = require("../utils");
const User = require("../models/userModels");
const { createUserDataValidator } = require("../utils/userValidator");
exports.checkUserId = catchAsync(async (req, res, next) => {
const { userId } = req.params;
const idIsValid = Types.ObjectId.isValid(userId);
if (!idIsValid) throw new AppError(404, "User does not exist..");
const userExists = await User.exists({ _id: userId });
if (!userExists) throw new AppError(404, "User does not exist..");
next();
});
В роутах ця middleware уже прописана раніше, бо ми її тільки рефакторили. Тому там нічого правити не треба.
В контролерах пропишемо обробку вибірку користувача за id.
exports.getOneUser = catchAsync(async (req, res) => {
const user = await User.findById(req.params.userId);
res.status(200).json({
msg: "Success",
user,
});
});
Рефакторинг оновлення користувача за id
Валідація оновлення даних. спрочатку пропишемо валідатор Joi в нащих утилітах. На перевірку вдповідності формату даних. PASSWD_REGEX - ми вказували раніще у файлі, коли прописували валідацію формату даних під час створення користувача. Також прибрали необхідність приходу всіх полів, оскільки з фронтенда може прийти оновлення тільки деякі параметри.
exports.updateUserDataValidator = (data) =>
Joi.object()
.options({ abortEarly: false })
.keys({
name: Joi.string().min(3).max(30),
year: Joi.number().min(1940).max(2023),
email: Joi.string().email(),
password: Joi.string().regex(PASSWD_REGEX),
role: Joi.string().valid(...Object.values(userRolesEnum)),
})
.validate(data);
Пропишемо middleware перед оновленням користувача. спочатку перевіряємо на відповідність формату даних, які приходять. а потім перевіряємо чи існує користувач із таким email в базі даних, якщо захочемо оновити пароль. Якщо аткий пароль існуєу іншого користувача то повертаємо помилку. якщо ваділація пройдена і немає збігів, то керування передається наступній функції в роутах. Тут тікавий синтаксис { email: value.email, _id: { $ne: req.params.id } }. Тут ми перевіряємо чи є такий email в базі даних, але за умовим що цей імейл не в поточному записі. тобто. такий імейл має бути тільки в одному записі в тому, який саме і оновлюємо. $ne - означає not exist
const { AppError, catchAsync, userValidator } = require("../utils");
exports.checkUpdateUserData = catchAsync(async (req, res, next) => {
const { error, value } = userValidator.updateUserDataValidator(req.body);
// 1. check if this email already exists
// 2. check if existed user id !== current user id
const userExists = await User.exists({ email: value.email, _id: { $ne: req.params.id } });
// if (userExists) return next(new AppError(409, 'User with this email exists..'));
if (userExists) throw new AppError(409, 'User with this email exists..');
if (error) {
console.log(error);
throw new AppError(400, "Invalid user data..");
}
req.body = value;
next();
});
Вкажемо цю перевірку в роутах
const { checkUpdateUserData } = require("../middlewares/userMiddlewares");
router.route("/:userId").get(getOneUser).patch(checkUpdateUserData, updateUser).delete(deleteUser);
рефакторинг контроллера:
Як найпростіший варіант:
exports.updateUser = catchAsync(async (req, res) => {
const { userId } = req.params;
// повертає стару версію юзера
// const updatedUser = await User.findByIdAndUpdate(id, { name: req.body.name });
// повертає оновлену версію юзера
const updatedUser = await User.findByIdAndUpdate(
userId,
{ name: req.body.name },
{ new: true }
);
res.status(200).json({
msg: "Success",
user: updatedUser,
});
});
АЛЕ ПРАВИЛЬНО ОНОВЛЮВАТИ ДАНІ В БД ПОЕТАПНО ОСТ ТАК ЧЕРЕЗ МЕТОЛ SAVE (але тут ми двічі звертаємося до бази даних):
exports.updateUser = catchAsync(async (req, res) => {
const { userId } = req.params;
const user = await User.findById(userId);
// переоновлюємо дані в отриманому користувачі
Object.keys(req.body).forEach((key) => {
user[key] = req.body[key];
});
const updatedUser = await user.save();
//const updatedUser = await userService.updateUser(req.params.id, req.body);
res.status(200).json({
msg: 'Success',
user: updatedUser,
});
});
Рефакторинг видалення користувача за id із бази даних
exports.deleteUser = catchAsync(async (req, res) => {
const { userId } = req.params;
await User.findByIdAndDelete(userId);
// як варіант res.sendStatus(204);
res.status(200).json({
msg: "Success",
});
});
Хешування паролів у БД
Для захисту даних потрібно хешувати паролі. І зберігати їх у базі даних лише у такому форматі. Для хешування паролів існує багато модулів.
Для фронтенду часто використовують bcrypt.js
Для бекенду використовують node.bcrypt.js
npm i bcrypt
Захешуємо пароль на етапі створення юзера в контроллерах. Спочатку створюється "соль" в яку передаємо цифрою рівень захищенності. зазвичай в проміжку мід 8 і 12. А щоб захешувати пароль потрібно використати метод hash і в нього передати пароль який хочемо захешувати і друшим параметром "соль". Новий користувач - че розпилений користувач із перезаписаним паролем тобто хешем замість нього.
const bcrypt = require('bcrypt');
exports.createUser = catchAsync(async (req, res) => {
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(req.body.password, salt);
const newUser = await User.create({ ...req.body, password: hashedPassword });
newUser.password = undefined;
res.status(201).json({
msg: "User created",
user: newUser,
});
});
Тепер в базу даних буде записуватися не пароль а його хеш. У такому разі виникає потреба звірення правильності ведення пароля. Для цього у bcrypt існує метод compare. У нього 1-м передають параметр для порівняння А 2-м наш захешований пароль. Він повертає true або false.
const isPasswordMatch = await bcrypt.compare("Aas123$$$", hashedPassword);
Хешування паролю в самій моделі при створенні користувача
Можна провести рефакторинг попереднього коду. Зазвичай хешування як додатковий сервіс виносять окрему функцію в папку utils.
Але є й інші варіанти - з використанням хуків. Свого роду гачок, який спрацьовує, коли відбувається певна подія.
Розглянемо для цього ще одну опцію створення запису в базі даних на прикладі контролера створення користувача
exports.createUser = catchAsync(async (req, res) => {
const newUser = new User(req.body);
await newUser.save();
// const newUser = await userService.createUser(req.body);
newUser.password = undefined;
res.status(201).json({
msg: "User created",
user: newUser,
});
});
Але повернемо на створення користувача такий синтаксис, на нього теж реагуватиме подальгий код. Тобто подальштй хук буде відпрацьовувати на методи create і save.
const newUser = await User.create(req.body);
І є мождивість проведення операцій в схемі перед самим створенням запису. Реагуватиме на метод save. Для цього порефакторимо нашу модель. підключаємо bcrypt, а потім використовуємо userSchema.pre. Пергий параметр - на який буде реагувати цей алгоритм, друший параметр колбек функція. ми перевіряємо, якщо не було змін в паролі, то виходимо, а якшо були, то хешуємо пароль і перезаписуємо його. ТОбто цей хук буде хешувати наш пароль при створенні чи збереженні юзера.
const bcrypt = require('bcrypt');
// Some code
// Pre save mongoose hook. Fires on Create and Save.
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
????
custom mongoose metod
можна створити у схемі метод наприклад checkPassword, і це асинхронна функція яка повертає проміс. і порівнює вона кандидата і хеш
/**
* Custom mongoose method to validate password. Will use in future.
* @param {string} candidate
* @param {string} hash
* @returns {Promise<boolean>}
*/
userSchema.methods.checkPassword = (candidate, hash) => bcrypt.compare(candidate, hash);
порядок написання бекенд додатку
запустити сервер (express + пережбачити помилку сервера і коли немає збігу 404 )
в пекедж json налаштувати nodemon і NODE_ENV В залежності в якому режимі запустили сервер
налаштувати оточення env. і завантаження відповідного env в задежності від NODE_ENV
прописати в оточенні урл на могно дб
підключити в головному файлі програми базу монго дб
пілключити всі необхідні мідлвари (логгер, корс, експрессджсон )
підключити в головному файлі посилання на всі необхіні роути.
у файлі роутів прописати всі використовувані методи роутів. переважно get (2 шт), post, put(patch), delete
підключити до файлу роутів відповідні контроллери
в контролерах у обгортці асинзронної функції, яка відловлює помилку прописати функціонал асинхронної функції (шаблон)
стоврити модель даних для бази (схема + модель) експортувати для використання в контролерах і мідлварах.
Покликання:
Last updated