express01.md 34 KB

Постановка задачи. Создание сервера express.js. Подключение и настройка sequelize.

Напишем полноценное АПИ для проекта "ресторан" используя express.js.

  1. Создадим проект и разберемся в его структуре.
  2. Подключение к БД (познакомимся с ORM Sequelize, научимся делать миграции)
  3. Разработаем конечные точки для получения меню и формирования корзины

Содержание:

Создание проекта

Для больших проектов лучше использовать генератор, но там много лишнего. Создадим простой проект:

  1. Создайте каталог для проекта и перейдите в него (в моём случае это каталог api)
  2. Запустите команду npm init для создания проекта

    На все вопросы отвечаем по-умолчанию, кроме entry point (точка входа), тут пишем app.js (можно оставить и по-умолчанию, это ни на что не влияет)

    package name: (api)
    version: (1.0.0)
    description:
    entry point: (index.js) app.js
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    test command:
    git repository:
    keywords:
    author:
    license: (ISC)
    type: (commonjs)
    
  3. Добавьте в зависимости проекта express.js командой npm i express

  4. Создайте файл .gitignore

    node_modules
    
  5. Создайте файл app.js

    Ниже пример "hello world" проекта с официального сайта express.js

    const express = require('express')
    const app = express()
    const port = 3000
    
    app.get('/', (req, res) => {
        res.send('Hello World!')
    })
    
    app.listen(port, () => {
        console.log(`Example app listening on port ${port}`)
    })
    

    Что здесь происходит?

    • const express = require('express') - импортируем модуль
    • const app = express() - создаём экземпляр приложения
    • const port = 3000 - определяем порт, на котором приложение будет слушать запросы.

      Прибивать гвоздями не совсем хорошо, но в перспективе мы завернём апи в контейнер. Сейчас главное чтобы порт никем не использовался.

    • app.get('/', (req, res) => {}) - endpoint (конечная точка), которая будет обрабатывать входящий запрос. В данном случае метод GET по пути /.

      В параметрах лямбда функции приходят объекты req (request - запрос, из этого объекта мы можем извлечь параметры и тело запроса) и res (response - ответ, сюда мы должны вернуть результат запроса)

    • app.listen(port, () => {}) - запуск сервера на указанном порту

Запустить проект можно командой node app.js.

В браузере должна открываться страница http://localhost:3000, возвращающая Hello World!

Подключение к БД

Для подключения к БД можно использовать пакет mysql, но для более-менее сложных проектов, подразумевающих дальнейшее развитие лучше использовать библиотеки, поддерживающие миграции (инициализация и изменение структуры базы данных). Для JavaScript наиболее популярна ORM библиотека sequelize. На хабре есть цикл статей, посвящённый этой библиотеке.

Всеми возможностями мы пользоваться не будем, нам достаточно миграций.

Установим зависимости

Выполняем в каталоге с проектом

npm i mysql2 sequelize sequelize-cli
  • Пакет mysql2 нужен для работы с БД MySQL (явно мы его не используем, но он нужен для sequelize)
  • Пакет sequelize нужен для использования в нашем коде
  • Приложение sequelize-cli нужно для создания и управление миграциями

Инициализация и настройка sequelize (только перед первым запуском)

Инициализация:

npx sequelize-cli init

Будут созданы следующие директории:

  • config — файл с настройками подключения к БД
  • models — модели для проекта
  • migrations — файлы с миграциями
  • seeders — файлы для заполнения БД начальными (фиктивными) данными

Настройка

Далее нам нужно сообщить CLI, как подключиться к БД. Для этого откроем файл config/config.json. Он выглядит примерно так:

{
  "development": {
    "username": "root",
    "password": null,
    "database": "database_development",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "test": {
    "username": "root",
    "password": null,
    "database": "database_test",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "production": {
    "username": "root",
    "password": null,
    "database": "database_production",
    "host": "127.0.0.1",
    "dialect": "mysql"
  }
}

То есть для разных сценариев мы можем использовать разные БД:

  • development - режим разработки
  • test - тестирование
  • production - "боевая" БД

Хранить логин/пароль к БД в открытом доступе нельзя, поэтому перепишите config/config.json таким образом:

{
  "development": {
    "use_env_variable": "DATABASE_URL",
    "dialect": "mysql"
  },
  "test": {
    "use_env_variable": "DATABASE_URL",
    "dialect": "mysql"
  },
  "production": {
    "use_env_variable": "DATABASE_URL",
    "dialect": "mysql"
  }
}

Sequelize поддерживает загрузку строки подключения из переменных окружения. Нам нужно создать переменную окружения в таком формате:

DATABASE_URL=mysql://[user]:[pass]@[sqldomain]/[db name]

В VSCode можно задать переменные в настройках:

И в разделе "configurations" добавьте объект "env":

Создание базы данных

npx sequelize-cli db:create [--url=<строка подключения>]

--url <строка подключения к БД> можно не указывать, если:

  • оставили стандартный вариант инициализации БД или в переменных окружения есть DATABASE_URL (учитывайте, что на командную строку не распространяются настойки VSCode)
  • в корне проекта создан файл .sequelizerc, в котором задана переменная url:

    module.exports = {
      'url': 'mysql://ekolesnikov:<тут пароль>@kolei.ru/restaurant'
    }
    

    Такой вариант предпочтительнее, чтобы не писать url при каждой миграции. Только не забудьте и этот файл прописать в .gitignore

Если всё настроили правильно, то при запуске выдаст примерно такое:

> npx sequelize-cli db:create

Sequelize CLI [Node: 23.9.0, CLI: 6.6.3, ORM: 6.37.7]

Parsed url mysql://ekolesnikov:*****@kolei.ru/restaurant
Database restaurant created.

Создадим миграцию для начальной инициализации базы данных

npx sequelize-cli migration:generate --name first

В каталоге migrations будет создан файл YYYYMMDDhhmmss-first.js:

'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up (queryInterface, Sequelize) {
    /**
     * Add altering commands here.
     *
     * Example:
     * await queryInterface.createTable('users', { id: Sequelize.INTEGER });
     */
  },

  async down (queryInterface, Sequelize) {
    /**
     * Add reverting commands here.
     *
     * Example:
     * await queryInterface.dropTable('users');
     */
  }
};

В методе up мы должны прописать команды для создания таблиц и связей, а в методе down для удаления. В первой минграции мы создадим таблицу MenuItem для хранения блюд (тут вроде все более менее понятно, подробно расписывать не буду - если что-то не понятно, то можно загуглить):

'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up (queryInterface, Sequelize) {
    await queryInterface.createTable('MenuItem', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.DataTypes.INTEGER
      },
      title: {
        type: Sequelize.DataTypes.STRING,
        allowNull: false,
        comment: 'название блюда'
      },
      image: {
        type: Sequelize.DataTypes.STRING,
        allowNull: false,
        comment: 'название файла с изображением блюда'
      },
      description: {
        type: Sequelize.DataTypes.TEXT,
        allowNull: true,
        comment: 'описание блюда'
      },
      price: {
        type: Sequelize.DataTypes.INTEGER,
        allowNull: false
      }
    })
  },

  async down (queryInterface, Sequelize) {
    await queryInterface.dropTable('MenuItem')
  }
}

И "накатим" её командой:

npx sequelize-cli db:migrate [--url <строка подключения к БД>]

Логи консоли:

> npx sequelize-cli db:migrate

Sequelize CLI [Node: 23.9.0, CLI: 6.6.3, ORM: 6.37.7]

Parsed url mysql://ekolesnikov:*****@kolei.ru/restaurant
== 20250514112031-first: migrating =======
== 20250514112031-first: migrated (0.240s)

Если вдруг что-то забыли, то можно "откатить" последнюю миграцию командой:

npx sequelize-cli db:migrate:undo

Использование sequelize для получения данных из БД

Возвращаемся к нашему app.js

Добавляем в начале файла импорт библиотеки

const { sequelize } = require('./models')
const { QueryTypes } = require('sequelize')

И поменяем endpoint (я добавил префикс /api, он нам понадобится при настройке nginx, когда будем заворачивать апи в контейнер):

app.get('/api/menu-item', async (req, res) => {
  try {
    res.json(await sequelize.query(`
      SELECT *
      FROM MenuItem
    `, {
      logging: false,
      type: QueryTypes.SELECT
    }))
  } catch (error) {
    console.error(error)
  } finally {
    res.end()
  }
})
  1. Запросы к БД асинхронные, поэтому заворачиваем код в async/await
  2. Список блюд, возвращаемый запросом sequelize.query заворачиваем в res.json(), это добавит в заголовок ответа Content-Type: application/json.

Пока у нас таблица блюд пустая, поэтому в ответе тоже будет пустой массив

Создание скрипта для наполнения БД начальными данными

Sequelize позволяет добавить записи в таблицы (можно использовать для первоначальной инициализации словарей или при тестировании)

npx sequelize-cli seed:generate --name menu-items

В каталоге seeders будет создан файл аналогичный файлам миграции (собственно и отличий между ними нет, просто логически выделены отдельно)

С помощью метода bulkInsert формируем список блюд для добавления:

'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up (queryInterface, Sequelize) {
    await queryInterface.bulkInsert('MenuItem', [   
      {id: 1, title: 'Салат', image: 'Салат.jpg', price: 100},
      {id: 2, title: 'Суп', image: 'Суп.jpg', price: 100},
      {id: 3, title: 'Компот', image: 'Компот.jpg', price: 100}
    ])
  },

  async down (queryInterface, Sequelize) {
    await queryInterface.bulkDelete(
      'MenuItem', 
      null /* тут можно прописать условие WHERE */
    )
  }
}

И запускаем добавление данных командой

npx sequelize-cli db:seed:all [--url=...]

Можно откатить отдельный импорт, но накатить можно только всё разом. Поэтому для заполнения словарей лучше использовать bulkInsert в обычной миграции, а seeders использовать для тестов.

Добавление сущностей БД, реализация REST.

Добавим в БД таблицу Cart (корзина) и связи между Cart и MenuItem

npx sequelize-cli migration:generate --name cart

И пропишем в созданном файле команды для создания таблицы с внешним ключем и команды для удаления:

В реальном проекте ещё нужно добавить таблицу пользователей и привязать корзину к пользователю

'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up (queryInterface, Sequelize) {
    await queryInterface.createTable('Cart', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.DataTypes.INTEGER
      },
      menuItemId: {
        type: Sequelize.DataTypes.INTEGER,
        allowNull: false,
        comment: 'для внешнего ключа'
      },
      quantity: {
        type: Sequelize.DataTypes.INTEGER,
        allowNull: false,
        comment: 'количество'
      }
    })

    /**
     * Внешний ключ корзина_блюдо
     */
    await queryInterface.addConstraint('Cart', {
      fields: ['menuItemId'],
      type: 'foreign key',
      name: 'FK_cart_menu-item',
      references: {
          table: 'MenuItem',
          field: 'id'
      },
      onDelete: 'no action',
      onUpdate: 'no action'
    })

  },

  async down (queryInterface, Sequelize) {
    await queryInterface.removeConstraint('Cart', 'FK_cart_menu-item')
    await queryInterface.dropTable('Cart')
  }
}

Накатываем миграцию командой npx sequelize-cli db:migrate и у нас в схеме бд появится таблица Cart:

CRUD для корзины

Для того, чтобы тело запроса автоматически преобразовывалось в объект body нужно после создания экземпляра приложения подключить middleware express.json() (middleware это функции, которые выполняются до обработки endpoints, используются для служебных целей, как в нашем случае, и в целях авторизации. Позже добавим jwt-авторизацию и напишем для неё middleware):

...
const app = express()
app.use(express.json())
...

Create. Для добавления записей в таблицу используется метод POST

app.post('/api/cart', async function(req, res) {
  try {
    await sequelize.query(`
      INSERT INTO Cart (menuItemId, quantity)
      -- значения :menuItemId и :quantity задаются в replacements
      VALUES (:menuItemId, :quantity)
    `,{
      logging: false,
      type: QueryTypes.INSERT,
      // объект replacements должен содержать значения для замены
      replacements: {
        menuItemId: req.body.menuItemId,
        quantity: req.body.quantity
      }
    })
    // ничего не возвращаем, но код ответа меняем на 201 (Created)
    // можно и не менять, по-умолчанию возвращает 200 (OK)
    res.status(201)
  } catch (error) {
    console.warn('ошибка при добавлении блюда в корзину:', error.message)
    // при ошибке возвращаем код ошибки 500 и текст ошибки
    res.status(500).send(error.message)
  } finally {
    res.end()
  }
})

Read. Чтение

Используется метод get. Пример приводить не буду, он аналогичен запросу списка блюд

Update. Редактирование корзины

Для редактирования в REST используются методы PUT или PATCH. PUT - полная перезапись (замещение объекта), PATCH - частичная перезапись (изменение части объекта)

Мы будем только менять количество, поэтому используем PATCH

// в пути можно описать параметр (поставив знак ":")
app.patch('/api/cart/:id', async (req, res) => {
  try {
    await sequelize.query(`
      UPDATE Cart 
      SET quantity=:quantity
      WHERE id=:id
    `,{
      logging: false,
      replacements: {
        // используем параметр из пути
        id: req.params.id,
        quantity: req.body.quantity
      }
    })
  } catch (error) {
    console.warn('ошибка при редактировании корзины:', error.message)
    res.status(500).send(error.message)
  } finally {
    res.end()
  }
})

Delete. Удаление блюда из корзины

Тут ничего нового

app.delete('/api/cart/:id', async (req, res) => {
  try {
    await sequelize.query(`
      DELETE 
      FROM Cart
      WHERE id=:id
    `,{
      logging: false,
      replacements: {
        id: req.params.id
      }
    })
  } catch (error) {
    console.warn('ошибка при удалении блюда из корзины:', error.message)
    res.status(500).send(error.message)
  } finally {
    res.end()
  }
}) 

JWT-авторизация

JSON Web Token (JWT) — это JSON объект, который определен в открытом стандарте RFC 7519. Он считается одним из безопасных способов передачи информации между двумя участниками. Для его создания необходимо определить заголовок (header) с общей информацией по токену, полезные данные (payload), такие как id пользователя, его роль и т.д. и подписи (signature).

Структура JWT

JWT состоит из трех частей: заголовок header, полезные данные payload и подпись signature. Давайте пройдемся по каждой из них.

Шаг 1. Создаем HEADER

Хедер JWT содержит информацию о том, как должна вычисляться JWT подпись. Хедер — это тоже JSON объект, который выглядит следующим образом:

header = { "alg": "HS256", "typ": "JWT"}

Поле typ не говорит нам ничего нового, только то, что это JSON Web Token. Интереснее здесь будет поле alg, которое определяет алгоритм хеширования. Он будет использоваться при создании подписи. HS256 — не что иное, как HMAC-SHA256, для его вычисления нужен лишь один секретный ключ (более подробно об этом в шаге 3). Еще может использоваться другой алгоритм RS256 — в отличие от предыдущего, он является ассиметричным и создает два ключа: публичный и приватный. С помощью приватного ключа создается подпись, а с помощью публичного только лишь проверяется подлинность подписи, поэтому нам не нужно беспокоиться о его безопасности.

Шаг 2. Создаем PAYLOAD

Payload — это полезные данные, которые хранятся внутри JWT. Эти данные также называют JWT-claims (заявки). В примере, который рассматриваем мы, сервер аутентификации создает JWT с информацией об id пользователя — userId.

payload = { "userId": "b08f86af-35da-48f2-8fab-cef3904660bd" }

Мы положили только одну заявку (claim) в payload. Вы можете положить столько заявок, сколько захотите. Существует список стандартных заявок для JWT payload — вот некоторые из них:

  • iss (issuer) — определяет приложение, из которого отправляется токен.
  • sub (subject) — определяет тему токена.
  • exp (expiration time) — время жизни токена.

Эти поля могут быть полезными при создании JWT, но они не являются обязательными. Если хотите знать весь список доступных полей для JWT, можете заглянуть в Wiki. Но стоит помнить, что чем больше передается информации, тем больший получится в итоге сам JWT. Обычно с этим не бывает проблем, но все-таки это может негативно сказаться на производительности и вызвать задержки во взаимодействии с сервером.

Шаг 3. Создаем SIGNATURE

Подпись вычисляется с использованием следующего псевдо-кода:

const SECRET_KEY = 'cAtwa1kkEy'
const unsignedToken = base64urlEncode(header) + '.' + base64urlEncode(payload)
const signature = HMAC-SHA256(unsignedToken, SECRET_KEY)

Алгоритм base64url кодирует хедер и payload, созданные на 1 и 2 шаге. Алгоритм соединяет закодированные строки через точку. Затем полученная строка хешируется алгоритмом, заданным в хедере на основе нашего секретного ключа.

// header eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
// payload eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ
// signature -xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM

Шаг 4. Теперь объединим все три JWT компонента вместе

Теперь, когда у нас есть все три составляющих, мы можем создать наш JWT. Это довольно просто, мы соединяем все полученные элементы в строку через точку.

const token = encodeBase64Url(header) + '.' + encodeBase64Url(payload) + '.' + encodeBase64Url(signature)
// JWT Token
// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM

Вы можете попробовать создать свой собственный JWT на сайте jwt.io.

Вернемся к нашему примеру. Теперь сервер аутентификации может слать пользователю JWT.

Как JWT защищает наши данные?

Очень важно понимать, что использование JWT НЕ скрывает и не маскирует данные. Причина, почему JWT используются — это проверка, что отправленные данные были действительно отправлены авторизованным источником. Как было продемонстрировано выше, данные внутри JWT закодированы и подписаны, обратите внимание, это не одно и тоже, что зашифрованы. Цель кодирования данных — преобразование структуры. Подписанные данные позволяют получателю данных проверить аутентификацию источника данных. Таким образом закодирование и подпись данных не защищает их. С другой стороны, главная цель шифрования — это защита данных от неавторизованного доступа.

Простыми словами

  1. При авторизации по логину/паролю сервер убеждается, что такой пользователь есть, формирует JWT-токен, содержащий полезную информацию и подпись, и возвращает этот токен клиенту (сайт, приложение)

  2. Клиент при последующих запрсах добавляет JWT-токен в заголовок запроса:

    Authorization: Bearer <JWT-токен>
    
  3. Сервер, получая JWT-токен уже не лезет в таблицу пользователей, а просто проверяет подпись токена, убеждаясь что она не подделана (ключ подписи есть только у севрера). А затем может использовать полезную информацию из токена в запросах к БД. Таким образом мы экономим на каждом входящем запросе одно обращение к БД (а работа с БД самое медленное действие, вычисление подписи намного дешевле)

Подключаем JWT-авторизацию к нашему проекту

Устанавливаем необходимые пакеты:

npm i jsonwebtoken md5
  • jsonwebtoken - нужен для формирования и проверки токена
  • md5 - для хеширования пароля

Подключаем зависимости

const md5 = require('md5')
const jwt = require('jsonwebtoken')

Задаём подпись

Этот параметр тоже нельзя светить, поэтому считываем из переменных окружения (добавьте в jaunch.json)

const JWT_SECRET = process.env.JWT_SECRET

Реализуем метод авторизации

/**
 * В теле запроса должен быть объект с логином и паролем:
 * {
 *  "login": "ваш логин",
 *  "password": "пароль"
 * }
 */
app.post('/api/user/login', async (req, res) => {
  try {
    // const user = await sequelize.query(`
    //   SELECT *
    //   FROM User
    //   WHERE login=:login
    // `, {
    //   // параметр plain нужен, чтобы запрос вернул не массив записей, а конкретную запись
    //   // если записи с таким логином нет, то вернет null
    //   plain: true,
    //   logging: false,
    //   type: QueryTypes.SELECT,
    //   replacements: {
    //     login: req.body.login
    //   }
    // })

    // у меня нет таблицы User, поэтому я сделал заглушку
    // вам нужно брать данные для авторизации из БД
    const user = {
      password: md5('123456'),
      id: 1,
      roleId: 1
    }

    if (user) {
      // хешируем пароль
      const passwordMD5 = md5(req.body.password)

      if (user.password == passwordMD5) {

        // формируем токен
        const jwtToken = jwt.sign({
            id: user.id, 
            firstName: user.firstName,
            roleId: user.roleId 
          }, 
          JWT_SECRET
        )

        res.json(jwtToken)
      } else {
        res.status(401).send('не верный пароль')
      }
    } else {
      res.status(404).send('пользователь не найден')
    }
  } catch (error) {
    console.warn('ошибка при авторизации:', error.message)
    res.status(500).send(error.message)
  } finally {
    res.end()
  }
})

Реализуем аутентификацию пользователя в запросе корзины, используя middleware

Middleware функция принимает на входе параметры req, res, next, где req, res соответственно запрос и ответ, а next нужно вызвать, если всё нормально и можно вызывать следующую middleware функцию (их может быть несколько)

/**
 * Middleware авторизации
 */
const authenticateJWT = (req, res, next) => {
  const authHeader = req.headers.authorization

  if (authHeader) {
    const token = authHeader.split(' ')

    if (token[0].toLowerCase() != 'bearer')
      return res.status(400).send('не поддерживаемый тип авторизации')

    jwt.verify(token[1], JWT_SECRET, (err, data) => {
      // если в результате есть ошибка (err)
      // то возвращаем статус forbidden
      if (err) return res.status(403).send(err)

      // иначе сохраняем полезные данные в объект user запроса
      req.user = data
      next()
    })
  } else {
    res.status(401).send('нет заголовка авторизации')
  }
}

Теперь добавляем эту функцию в запрос получения корзины (обратите внимание, у меня в бд нет пользователей, поэтому я закомментировал часть кода, в вашем проекте нужно раскомментировать):

/**
 * Вторым параметром запроса можно добавить массив middleware
 */
app.get('/api/cart', [authenticateJWT], async (req, res) => {
  try {
    res.json(await sequelize.query(`
      SELECT *
      FROM Cart
      -- в моей БД нет пользователей
      -- WHERE userId=:userId
    `, {
      logging: false,
      type: QueryTypes.SELECT
      // replacements: {
      //   userId: req.user.id
      //               ^^^^ обратите внимание, тут используется объект user, заполненный в middleware аутентификации
      // }
    }))
  } catch (error) {
    console.error(error)
  } finally {
    res.end()
  }
})

Задание

Реализовать АПИ для своего курсового проекта по образцу. В следующем году постараюсь написать лекции по DevOps (завернём АПИ, базу и приложение в контейнеры).