👾
Node.js
  • 🧑‍💻Full-Stack Web Developer
  • 📚Теорія
    • 1️⃣Основи Node.js
      • Вступ
      • Модулі Node.js
      • Запуск скриптів модулів в Node.js
      • Структура проєкту, експорт-імпорт, index.js як хаб
      • Модулі CommonJS
      • Модулі MJS
      • Модулі ECMAScript
      • Модулі NPM + базові модулі
      • Глобальні змінні
      • Робота з файлами
    • 2️⃣Консольні додатки
      • Створення консольних додатків
    • 3️⃣Фреймворк Express
      • Про Express
      • Nodemon і запуски скриптів
      • Postman
      • Проміжне ПЗ middleware
      • Передача даних на сервер
      • Роутінг
      • CRUD
      • Налаштування лінтера
    • 4️⃣REST API
      • Змінні оточення
      • Логування
      • REST
      • Методи HTTP
      • CORS
      • Формування URL для REST API
      • Контроллери відсутнього роуту і непередбачуваної помилки
      • Валідація даних Joi
      • Рефакторинг додатку за MVC архітектурою
      • Express автогенератор додатку
    • 5️⃣База даних Mongo.DB
      • Основи MongoDB
      • Налаштування Mongo Atlas
      • Встановлення локальної MongoDB і основні команди
    • 6️⃣ODM Mongoose
      • Mongoose
      • Порядок планування бекенд додатку
      • чорнетка
    • 7️⃣Автентифякація WJT
      • чорнетка
      • чорнетка 2
    • 8️⃣Файли
      • чернетка
    • 9️⃣тестування
      • чернетка
    • 🔟Page 14
      • імейли
    • чорнетка докер
    • чорнетка сокети
    • додаткові матеріали
    • 👷Практика
      • 1️⃣Page 4
      • 2️⃣Page 5
      • 3️⃣Page 6
      • 4️⃣Page 7
      • 5️⃣Page 8
      • 6️⃣Page 9
  • Про мене
    • Про мене
Powered by GitBook
On this page
  • Модель
  • Рефакторинг Валідації
  • Валідація у проміжному пз
  • Підключення до MongoDB
  • Рефакторинг контроллерів для роботи з БД для створення користувача і отримання списку користувачів.
  • Опціональні конфіги схеми
  • Рефакторинг контроллера отримання користувача за id
  • Рефакторинг оновлення користувача за id
  • Рефакторинг видалення користувача за id із бази даних
  • Хешування паролів у БД
  • Хешування паролю в самій моделі при створенні користувача
  1. Теорія
  2. ODM Mongoose

чорнетка

PreviousПорядок планування бекенд додаткуNextАвтентифякація WJT

Last updated 1 year ago

У налаштуванні Mongo Atlas ми отримали URL підключення до бази даних. Пропишемо його у нашому файлі .env в проєкті. І при завантаженні середовища посилання на підключення до бази даних буде зберігатися в process.env.MONGO_URL.

enviroment/developement.env
MONGO_URL='mongodb+srv://khomiak:password1234@cluster0.z7jjmxa.mongodb.net/khomiak_db'

Для роботи з базою даних MongoDB в проєкті будемо використовувати додатковий пакет .

terminal
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); - по суті створення моделі, назву якій задаємо як перший параметр (з великої літери), а другий вказується схема моделі.

models/userModel.js
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;

Сталі значення зазвичай заведено виносити в окремі файли. (це, наприклад переліки можливих варіантів вибору). Якщо подивимося, то ми їх використовуємо у схемі налаштувань користувача вище. Винесли в окремий файл, бо можемо використовувати й в інших модулях нашого проєкту.

constants/userRolesEnum.js
const userRolesEnum = {
  ADMIN: 'admin',
  MODERATOR: 'moderator',
  USER: 'user',
};

module.exports = userRolesEnum;

Рефакторинг Валідації

Тепер трохи рефакторнемо нашу валідацію Joi. Зокрема під нашу схему напишемо валідацію перевірки. Для пароля будемо використовувати патерн із регулярного виразу. У ньому {8,128} означає, що пароль має бути від 8 до 128 символів. Також обовʼязково повинна бути велика, мала літера, число і якийсь спецсимвол.

Розберемо змінену валідацію прописану раніше.

Із нового тут regex - приймає значення і перевіряє на відповідність регулярному виразу вказаному в круглих дужках.

Там де перевірка ролі користувача у полі valid вказуються всі можливі значення.

utils/userValidator.js
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 користувача в базі даних. Якщо є, то генеруємо помилку. Якщо немає, то перевірка пройдена, перезаписуємо провалідовані дані та передаємо виконання коду наступній функції обробки.

middlewares/userMiddlewares.js
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 потрібно вказати в роутах, як проміжне пз.

routes/userRoutes.js
const { checkCreateUserData } = require("../middlewares/userMiddlewares");

//code
router.route("/").post(checkCreateUserData, createUser).get(getAllUsers);

Підключення до MongoDB

Щоб мати доступ до бази даних потрібно її під'єднати у головному файлі index.js.

Для цього імпортуємо mongoose і підключаємо перед middleware нашу базу даних. Далі наведено один із прикладів підʼєднання бази даних через метод connect. І оскільки це асинхронна операція, то повертає проміс, тому прописуємо then і catch. Якщо помилка виникає, то виходимо з процесу.

index.js
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.

controllers/userControllers.js
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-запитів у контролерах можна вказати параметри, які ми хочемо повернути (в прикладі повернеться тільки імʼя і рік):

controllers/userControllers.js
const users = await User.find().select("name year");

Або параметри, які хочемо прибрати із результату, який повертається (в прикладі повернеться все крім імені (ну і пароля, який не повертається через налаштування схеми)):

controllers/userControllers.js
const users = await User.find().select("-name");

Якщо ми хочемо примусово витягнути якесь поле, яке в схемі позначено, що не провертається (пароль) то потрібно вказати +.

controllers/userControllers.js
const users = await User.find().select("+password");

Також може повертатися версія обʼєкта, яку можна прибрати і не повертати

controllers/userControllers.js
const users = await User.find().select("-__v");

Але при створенні нового юзера у нас все одно у відповідь повертається обʼєкт з паролем. тобто цей select не відпрацьовується на новостворений обʼєкт. Для цього при створенні користувача перед поверненням присвоїмо паролю значення undefind. Тобто приховані дані з res потрібно завжди ЗАБИРАТИ!!

controllers/userControllers.js
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,
}
Код схеми повністю
models/userModel.js
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'],
      enum: Object.values(userRolesEnum),
      default: userRolesEnum.USER,
    },
  },
  {
    timestamps: true,
    versionKey: false,
  }
);

const User = model('User', userSchema);

module.exports = User;

versionKey: false - означає, що нам не буде повертатися ключ версії __v і не треба вказувати, зоб його прибирати, як було розглянуто раніше

timestamps: true - додає в базу даних інформацію, коли обʼєкт створено і коли обʼєкт оновлено. (буває корисно)

Рефакторинг контроллера отримання користувача за id

спочатку перепишемо middleware перевірки id користувача(пам'ятаємо, що в рутах він іде як userId).

За допомогою Types ми можемо перевірити чи валідний сам по собі userId об'єкту _id. Друга перевірка - чи існує такий користувач із таким полем.

middlewares/userMiddlewares.js
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.

controllers/userControllers.js
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 - ми вказували раніще у файлі, коли прописували валідацію формату даних під час створення користувача. Також прибрали необхідність приходу всіх полів, оскільки з фронтенда може прийти оновлення тільки деякі параметри.

utils/userValidator.js
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();
});

Вкажемо цю перевірку в роутах

routes/userRoutes.js
const { checkUpdateUserData } = require("../middlewares/userMiddlewares");

router.route("/:userId").get(getOneUser).patch(checkUpdateUserData, updateUser).delete(deleteUser);

рефакторинг контроллера:

Як найпростіший варіант:

controllers/userControllers.js
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",
  });
});

Хешування паролів у БД

Для захисту даних потрібно хешувати паролі. І зберігати їх у базі даних лише у такому форматі. Для хешування паролів існує багато модулів.

terminal
npm i bcrypt

Захешуємо пароль на етапі створення юзера в контроллерах. Спочатку створюється "соль" в яку передаємо цифрою рівень захищенності. зазвичай в проміжку мід 8 і 12. А щоб захешувати пароль потрібно використати метод hash і в нього передати пароль який хочемо захешувати і друшим параметром "соль". Новий користувач - че розпилений користувач із перезаписаним паролем тобто хешем замість нього.

controllers/userControllers.js
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.

Але є й інші варіанти - з використанням хуків. Свого роду гачок, який спрацьовує, коли відбувається певна подія.

Розглянемо для цього ще одну опцію створення запису в базі даних на прикладі контролера створення користувача

controllers/userControllers.js
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.

controllers/userControllers.js
  const newUser = await User.create(req.body);

І є мождивість проведення операцій в схемі перед самим створенням запису. Реагуватиме на метод save. Для цього порефакторимо нашу модель. підключаємо bcrypt, а потім використовуємо userSchema.pre. Пергий параметр - на який буде реагувати цей алгоритм, друший параметр колбек функція. ми перевіряємо, якщо не було змін в паролі, то виходимо, а якшо були, то хешуємо пароль і перезаписуємо його. ТОбто цей хук буде хешувати наш пароль при створенні чи збереженні юзера.

models/userModel.js
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, і це асинхронна функція яка повертає проміс. і порівнює вона кандидата і хеш

models/userModel.js
/**
 * 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);

порядок написання бекенд додатку

  1. запустити сервер (express + пережбачити помилку сервера і коли немає збігу 404 )

  2. в пекедж json налаштувати nodemon і NODE_ENV В залежності в якому режимі запустили сервер

  3. налаштувати оточення env. і завантаження відповідного env в задежності від NODE_ENV

  4. прописати в оточенні урл на могно дб

  5. підключити в головному файлі програми базу монго дб

  6. пілключити всі необхідні мідлвари (логгер, корс, експрессджсон )

  7. підключити в головному файлі посилання на всі необхіні роути.

  8. у файлі роутів прописати всі використовувані методи роутів. переважно get (2 шт), post, put(patch), delete

  9. підключити до файлу роутів відповідні контроллери

  10. в контролерах у обгортці асинзронної функції, яка відловлює помилку прописати функціонал асинхронної функції (шаблон)

  11. стоврити модель даних для бази (схема + модель) експортувати для використання в контролерах і мідлварах.

Покликання:

Для фронтенду часто використовують

Для бекенду використовують

📚
6️⃣
mongoose
нашого проєкту
bcrypt.js
node.bcrypt.js
Mongoose