Напишем полноценное АПИ для проекта "ресторан" используя express.js.
Содержание:
Для больших проектов лучше использовать генератор, но там много лишнего. Создадим простой проект:
api)Запустите команду 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)
Добавьте в зависимости проекта express.js командой npm i express
Создайте файл .gitignore
node_modules
Создайте файл 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 нужно для создания и управление миграциями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"
}
}
То есть для разных сценариев мы можем использовать разные БД:
Хранить логин/пароль к БД в открытом доступе нельзя, поэтому перепишите 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
Возвращаемся к нашему 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()
}
})
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 использовать для тестов.
Добавим в БД таблицу 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:
Для того, чтобы тело запроса автоматически преобразовывалось в объект body нужно после создания экземпляра приложения подключить middleware express.json() (middleware это функции, которые выполняются до обработки endpoints, используются для служебных целей, как в нашем случае, и в целях авторизации. Позже добавим jwt-авторизацию и напишем для неё middleware):
...
const app = express()
app.use(express.json())
...
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()
}
})
Используется метод get. Пример приводить не буду, он аналогичен запросу списка блюд
Для редактирования в 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()
}
})
Тут ничего нового
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()
}
})
JSON Web Token (JWT) — это JSON объект, который определен в открытом стандарте RFC 7519. Он считается одним из безопасных способов передачи информации между двумя участниками. Для его создания необходимо определить заголовок (header) с общей информацией по токену, полезные данные (payload), такие как id пользователя, его роль и т.д. и подписи (signature).
JWT состоит из трех частей: заголовок header, полезные данные payload и подпись signature. Давайте пройдемся по каждой из них.
Хедер JWT содержит информацию о том, как должна вычисляться JWT подпись. Хедер — это тоже JSON объект, который выглядит следующим образом:
header = { "alg": "HS256", "typ": "JWT"}
Поле typ не говорит нам ничего нового, только то, что это JSON Web Token. Интереснее здесь будет поле alg, которое определяет алгоритм хеширования. Он будет использоваться при создании подписи. HS256 — не что иное, как HMAC-SHA256, для его вычисления нужен лишь один секретный ключ (более подробно об этом в шаге 3). Еще может использоваться другой алгоритм RS256 — в отличие от предыдущего, он является ассиметричным и создает два ключа: публичный и приватный. С помощью приватного ключа создается подпись, а с помощью публичного только лишь проверяется подлинность подписи, поэтому нам не нужно беспокоиться о его безопасности.
Payload — это полезные данные, которые хранятся внутри JWT. Эти данные также называют JWT-claims (заявки). В примере, который рассматриваем мы, сервер аутентификации создает JWT с информацией об id пользователя — userId.
payload = { "userId": "b08f86af-35da-48f2-8fab-cef3904660bd" }
Мы положили только одну заявку (claim) в payload. Вы можете положить столько заявок, сколько захотите. Существует список стандартных заявок для JWT payload — вот некоторые из них:
Эти поля могут быть полезными при создании JWT, но они не являются обязательными. Если хотите знать весь список доступных полей для JWT, можете заглянуть в Wiki. Но стоит помнить, что чем больше передается информации, тем больший получится в итоге сам JWT. Обычно с этим не бывает проблем, но все-таки это может негативно сказаться на производительности и вызвать задержки во взаимодействии с сервером.
Подпись вычисляется с использованием следующего псевдо-кода:
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
Теперь, когда у нас есть все три составляющих, мы можем создать наш JWT. Это довольно просто, мы соединяем все полученные элементы в строку через точку.
const token = encodeBase64Url(header) + '.' + encodeBase64Url(payload) + '.' + encodeBase64Url(signature)
// JWT Token
// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM
Вы можете попробовать создать свой собственный JWT на сайте jwt.io.
Вернемся к нашему примеру. Теперь сервер аутентификации может слать пользователю JWT.
Очень важно понимать, что использование JWT НЕ скрывает и не маскирует данные. Причина, почему JWT используются — это проверка, что отправленные данные были действительно отправлены авторизованным источником. Как было продемонстрировано выше, данные внутри JWT закодированы и подписаны, обратите внимание, это не одно и тоже, что зашифрованы. Цель кодирования данных — преобразование структуры. Подписанные данные позволяют получателю данных проверить аутентификацию источника данных. Таким образом закодирование и подпись данных не защищает их. С другой стороны, главная цель шифрования — это защита данных от неавторизованного доступа.
При авторизации по логину/паролю сервер убеждается, что такой пользователь есть, формирует JWT-токен, содержащий полезную информацию и подпись, и возвращает этот токен клиенту (сайт, приложение)
Клиент при последующих запрсах добавляет JWT-токен в заголовок запроса:
Authorization: Bearer <JWT-токен>
Сервер, получая JWT-токен уже не лезет в таблицу пользователей, а просто проверяет подпись токена, убеждаясь что она не подделана (ключ подписи есть только у севрера). А затем может использовать полезную информацию из токена в запросах к БД. Таким образом мы экономим на каждом входящем запросе одно обращение к БД (а работа с БД самое медленное действие, вычисление подписи намного дешевле)
npm i 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 функция принимает на входе параметры 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 (завернём АПИ, базу и приложение в контейнеры).