Course
Обычный список дел поначалу работает отлично: вы добавляете задачи и закрываете их по мере выполнения. Но по мере роста количества задач пользоваться им становится неудобно. Одно из решений — добавить категоризацию: каждая задача будет относиться к категориям вроде Work, Study, Personal и т. п., а вы сможете фильтровать список по нужной категории.
В этом руководстве вы научитесь создавать REST API, которое позволяет организовывать задачи по категориям с помощью Node.js и MongoDB. В итоге у вас будет рабочий сервер Node.js с пятью конечными точками: create, list, get, update и delete, а также с фильтром по категории. Вы также увидите, как обеспечить проверку категорий с использованием нативного драйвера MongoDB, без ODM вроде Mongoose.
Чему вы научитесь
- Как создать REST API на Node.js, Express и MongoDB, включая полноценные CRUD-эндпоинты
- Как валидировать ввод, ограничивать категории и безопасно работать со значениями ObjectId
- Как структурировать и тестировать API, а затем подключить его к фронтенду
Полный код этого руководства вы найдёте на GitHub, если вам удобнее клонировать репозиторий и читать по ходу.
Требования
Перед началом у вас должно быть:
- Node.js 18+ (npm включён по умолчанию)
- Локально запущенный MongoDB или бесплатный кластер MongoDB Atlas
- Postman или любой HTTP‑клиент
- Базовые знания JavaScript и концепций REST
Шаг 1. Настройка проекта
Приступим к созданию простого менеджера задач. Сначала создадим новую папку и инициализируем проект Node, выполнив команды ниже в терминале:
mkdir task-manager-categories
cd task-manager-categories
npm init -y
Далее установим четыре рантайм-зависимости, необходимые для работы приложения: express для роутинга, официальный драйвер MongoDB, dotenv для загрузки переменных окружения и, наконец, helmet для заголовков безопасности.
npm install express mongodb dotenv helmet
Для разработки установите nodemon как dev‑зависимость командой ниже, чтобы сервер автоматически перезагружался при изменении файлов:
npm install --save-dev nodemon
Затем откройте проект в IDE и обновите раздел scripts в package.json следующим образом. Это позволит запускать сервер с помощью npm start или в режиме разработки с npm run dev:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Вот структура, которую мы поэтапно соберём. У каждой папки своя роль: db/ отвечает за подключение к базе, lib/ содержит вспомогательные функции, middleware/ — для middleware Express, а routes/ определяет обработчики запросов. Папка public/ будет хранить небольшой фронтенд, который мы добавим позже. Даже в маленьком проекте такая структура помогает не запутаться и сразу понимать, куда добавлять новый код. Можете создать каркас папок сейчас и заполнять по ходу или пропустить и добавлять файлы по мере выполнения шагов:
task-manager-categories/
├── db/
│ └── connect.js
├── lib/
│ └── taskDocument.js
├── middleware/
│ └── parseObjectId.js
├── routes/
│ └── tasks.js
├── public/
│ ├── index.html
│ ├── styles.css
│ └── app.js
├── .env
├── .env.example
├── .gitignore
├── package.json
└── server.js
Шаг 2. Подключение к MongoDB
Чтобы сохранять и получать задачи, сначала подключим приложение к MongoDB. Это делается с помощью строки подключения, которая сообщает драйверу MongoDB, как соединиться с базой. Вместо того чтобы жёстко прописывать её в коде, лучше хранить её в файле окружения. Так конфиденциальные значения не попадут в репозиторий, а переключаться между окружениями станет проще.
Создайте файл .env.example для документации требуемых переменных и файл .env с вашими фактическими значениями, как показано ниже:
# Copy this file to `.env` and fill in real values.
# --- Local MongoDB ---
# Use this if you're running MongoDB locally
MONGO_URI=mongodb://127.0.0.1:27017
# --- MongoDB Atlas ---
# Replace <username>, <password>, and <cluster-url> with your actual values
# Example: mongodb+srv://user:pass@cluster0.abcde.mongodb.net/
# MONGO_URI=mongodb+srv://<username>:<password>@<cluster-url>/?retryWrites=true&w=majority
DB_NAME=taskmanager
PORT=3000
Теперь, когда у нас есть строка подключения, используем её для настройки соединения с MongoDB. Создайте файл db/connect.js. Здесь мы инициализируем клиент MongoDB и сделаем его доступным для остальной части приложения:
const { MongoClient } = require("mongodb");
let client;
let db;
async function connectDB() {
if (!process.env.MONGO_URI) {
throw new Error("MONGO_URI is not set. Check your .env file.");
}
client = new MongoClient(process.env.MONGO_URI, {
appName: "devrel-tutorial-javascript-crud-geeksforgeeks",
});
await client.connect();
db = client.db(process.env.DB_NAME || "taskmanager");
await db.collection("tasks").createIndex({ category: 1 });
console.log(`MongoDB connected (db: ${db.databaseName})`);
}
function getTasksCollection() {
if (!db) {
throw new Error("Database not initialized. Call connectDB() first.");
}
return db.collection("tasks");
}
async function closeDB() {
if (client) await client.close();
}
module.exports = { connectDB, getTasksCollection, closeDB };
Этот файл создаёт единый клиент MongoDB, который использует всё приложение. Инициализировать его нужно один раз — драйвер и так управляет пулом соединений под капотом. Создание нового клиента на каждый запрос поначалу может показаться безвредным, но очень быстро приведёт к проблемам с производительностью.
Шаг 3. Определяем структуру задачи и валидацию
На этом этапе приложение может подключаться к MongoDB. Теперь нужно определить, как выглядит задача, прежде чем что‑то сохранять.
Здесь использование нативного драйвера MongoDB ощущается иначе. Нет отдельного файла схемы, как в Mongoose. Вместо этого «схема» — это форма объекта, который вы вставляете в базу. Сначала это может показаться излишне свободным, но на деле это полезно: вы остаетесь ближе к тому, как MongoDB работает на самом деле, и ничего не скрыто за абстракциями.
Тем не менее валидация нам всё равно нужна. И вместо того чтобы размазывать логику по разным маршрутам, мы сосредоточим её в одном месте, чтобы везде действовали одинаковые правила. Создайте файл под названием lib/taskDocument.js и добавьте в него код ниже:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
const ALLOWED_CATEGORIES = ['Work', 'Personal', 'Study', 'Other'];
function assertValidCategory(category) {
if (!ALLOWED_CATEGORIES.includes(category)) {
throw new ValidationError(
`category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
);
}
}
function buildTaskDocument(body = {}) {
if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
throw new ValidationError('title is required and must be a non-empty string');
}
if (body.category != null) {
assertValidCategory(body.category);
}
const now = new Date();
return {
title: body.title.trim(),
description: typeof body.description === 'string' ? body.description.trim() : '',
category: body.category != null ? body.category : 'Other',
completed: Boolean(body.completed),
createdAt: now,
updatedAt: now
};
}
function buildTaskUpdate(body = {}) {
const updates = {};
if (typeof body.title === 'string' && body.title.trim()) {
updates.title = body.title.trim();
}
if (typeof body.description === 'string') {
updates.description = body.description.trim();
}
if (body.category != null) {
assertValidCategory(body.category);
updates.category = body.category;
}
if (body.completed != null) {
updates.completed = Boolean(body.completed);
}
if (Object.keys(updates).length === 0) {
throw new ValidationError('no valid fields provided for update');
}
updates.updatedAt = new Date();
return updates;
}
module.exports = {
ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
};
Этот файл — «сторож» всего, что попадает в базу данных. Каждый запрос на создание или обновление проходит через него, так что правила применяются в одном месте.
Он следит за корректной формой задач, поддерживает единообразие категорий и помогает избежать сюрпризов при запросах к данным. Пользовательский класс ValidationError также даёт аккуратный способ отделять некорректный ввод от реальных проблем сервера, чтобы API отвечало соответствующим образом. С этим на месте остальная часть приложения может оставаться простой. Каждый маршрут занимается своим делом, будучи уверенным, что получает уже валидные данные. Далее подключим маршруты и начнём сохранять задачи в MongoDB.
Шаг 4. Обработка ObjectId и валидация запросов
Теперь, когда мы знаем, как выглядит задача, разберёмся с тем, как на неё ссылаться. Когда маршрут включает параметр :id, он поступает как обычная строка. Но MongoDB ожидает ObjectId. Если строка некорректна, драйвер выбрасывает не слишком полезную ошибку. Вместо того чтобы обрабатывать это в каждом маршруте, мы централизуем логику в middleware, чтобы все конечные точки вели себя одинаково.
Для этого создайте файл middleware/parseObjectId.js и добавьте следующее:
const { ObjectId } = require('mongodb');
function parseObjectId(req, res, next) {
const { id } = req.params;
if (!ObjectId.isValid(id)) {
return res.status(400).json({ error: 'invalid task id' });
}
req.taskId = new ObjectId(id);
next();
}
module.exports = parseObjectId;
Этот middleware выполняется до обработчика маршрута. Он проверяет корректность значения id, преобразует его в ObjectId и прикрепляет к req.taskId. Так к моменту выполнения логики маршрута вы всегда работаете с корректным ObjectId, а некорректные вводимые данные отвергаются заранее с понятным ответом 400. Далее подключим это к маршрутам и начнём всё связывать вместе.
Шаг 5. Создание маршрутов задач (CRUD + фильтрация)
На этом основная работа уже сделана. Мы определили, как выглядит валидная задача, и обработали разбор и проверку ID. Значит, обработчики маршрутов могут сосредоточиться на одном — общении с базой данных.
Перейдём к созданию реальных маршрутов API: создайте файл routes/tasks.js и добавьте в него следующее:
const express = require('express');
const { getTasksCollection } = require('../db/connect');
const {
ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
} = require('../lib/taskDocument');
const parseObjectId = require('../middleware/parseObjectId');
const router = express.Router();
// POST /tasks
router.post('/', async (req, res, next) => {
try {
const doc = buildTaskDocument(req.body);
const result = await getTasksCollection().insertOne(doc);
res.status(201).json({ _id: result.insertedId, ...doc });
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// GET /tasks (optionally ?category=Work)
router.get('/', async (req, res, next) => {
try {
const { category } = req.query;
if (category && !ALLOWED_CATEGORIES.includes(category)) {
return res.status(400).json({
error: `category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
});
}
const filter = category ? { category } : {};
const tasks = await getTasksCollection()
.find(filter)
.sort({ createdAt: -1 })
.toArray();
res.json(tasks);
} catch (err) { next(err); }
});
// GET /tasks/:id
router.get('/:id', parseObjectId, async (req, res, next) => {
try {
const task = await getTasksCollection().findOne({ _id: req.taskId });
if (!task) return res.status(404).json({ error: 'task not found' });
res.json(task);
} catch (err) { next(err); }
});
// PUT /tasks/:id
router.put('/:id', parseObjectId, async (req, res, next) => {
try {
const updates = buildTaskUpdate(req.body);
const result = await getTasksCollection().findOneAndUpdate(
{ _id: req.taskId },
{ $set: updates },
{ returnDocument: 'after' }
);
if (!result) return res.status(404).json({ error: 'task not found' });
res.json(result);
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// DELETE /tasks/:id
router.delete('/:id', parseObjectId, async (req, res, next) => {
try {
const result = await getTasksCollection().deleteOne({ _id: req.taskId });
if (result.deletedCount === 0) {
return res.status(404).json({ error: 'task not found' });
}
res.status(204).end();
} catch (err) { next(err); }
});
module.exports = router;
Каждый маршрут в коде выше следует одному и тому же шаблону: берёт входные данные из запроса, пропускает через созданные ранее помощники, обращается к MongoDB и возвращает ответ. Поскольку валидация и разбор ID уже реализованы, код остаётся небольшим и предсказуемым. На практике это выглядит так:
- Маршрут POST формирует новую задачу с помощью buildTaskDocument перед вставкой
- Маршруты GET при необходимости фильтруют по категории и возвращают результаты, отсортированные по новизне
- Маршрут PUT использует buildTaskUpdate, поэтому частичные обновления безопасны и единообразны
- Маршрут DELETE удаляет задачу по уже разобранному ObjectId
В этом коде также есть несколько деталей, на которые стоит обратить внимание:
- find() возвращает курсор, а не массив, поэтому после сортировки мы вызываем .toArray(), чтобы получить результаты
- findOneAndUpdate с опцией returnDocument: 'after' сразу возвращает обновлённый документ
- Мы возвращаем корректные коды HTTP: 201 для создания, 204 для удаления и 400 или 404 по ситуации
- Любые неожиданные ошибки передаются в next(err), чтобы обрабатывать их централизованно, а не в каждом маршруте
Шаг 6. Связываем всё на сервере
На этом этапе у нас есть всё необходимое: валидация, аккуратные маршруты и рабочее подключение к базе. Осталось связать всё вместе и запустить сервер. Создайте файл server.js. Это точка входа приложения, где сходятся все части:
require('dotenv').config();
const path = require('path');
const express = require('express');
const helmet = require('helmet');
const { connectDB, closeDB } = require('./db/connect');
const tasksRouter = require('./routes/tasks');
if (!process.env.MONGO_URI) {
console.error('Fatal: MONGO_URI is not set. Copy .env.example to .env and fill it in.');
process.exit(1);
}
const PORT = Number(process.env.PORT) || 3000;
const app = express();
app.use(helmet());
app.use(express.json({ limit: '100kb' }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'task-manager-with-categories' });
});
app.use('/tasks', tasksRouter);
app.use((req, res) => {
res.status(404).json({ error: 'not found' });
});
app.use((err, req, res, next) => {
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'payload too large' });
}
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'invalid JSON body' });
}
console.error(err);
res.status(500).json({ error: 'internal server error' });
});
async function start() {
try {
await connectDB();
const server = app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
const shutdown = () => {
console.log('\nShutting down gracefully...');
server.close(async () => {
await closeDB();
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
} catch (err) {
console.error('Failed to start server:', err);
process.exit(1);
}
}
if (require.main === module) {
start();
}
module.exports = { app, start };
Этот файл аккуратно связывает всё воедино. Он проверяет наличие переменных окружения до запуска, применяет базовые меры безопасности с помощью helmet и ограничивает размер входящего JSON, чтобы приложение случайно не принимало слишком большие тела запросов. Также он обслуживает папку public/, так что фронтенд может жить в том же приложении без отдельного сервера.
Все маршруты примонтированы под /tasks, а для всего, что не совпало, возвращается аккуратный 404. Ошибки обрабатываются централизованно, а не дублируются в каждом маршруте — так проще поддерживать. Подключение к базе устанавливается до старта прослушивания порта, поэтому сервер не принимает запросы, пока не готов, а завершение работы происходит корректно: соединения закрываются как следует.
Теперь запустим всё и проверим, что работает, командой npm run dev. Если всё настроено правильно, вы увидите примерно следующее:
MongoDB connected (db: taskmanager)
Server running on http://localhost:3000
На этом всё связано и запущено! У нас есть полностью рабочее API, можно приступать к тестированию.
Шаг 7. Тестирование конечных точек API
Прежде чем строить интерфейс, полезно убедиться, что каждый эндпоинт работает сам по себе. Так отлаживать намного проще: сразу видно, в API ли проблема или во фронтенде. Используем Postman (или любой HTTP‑клиент) и выполним несколько запросов по порядку. Каждый следующий опирается на данные, созданные на предыдущем шаге.
Создайте задачу: отправьте запрос POST с заголовком, описанием и категорией. Если всё в порядке, API вернёт 201 Created с новой задачей и сгенерированным _id.
POST http://localhost:3000/tasks
Content-Type: application/json
{
"title": "Write GeeksForGeeks article",
"description": "First draft by Friday",
"category": "Work"
}
Создайте ещё несколько: выполните тот же запрос POST с разными телами, чтобы получить достаточно данных для тестирования списка и фильтрации. После этого у вас будет небольшой набор для запросов.
{ "title": "Go for a run", "category": "Personal" }
{ "title": "Read MongoDB docs", "category": "Study" }
{ "title": "Buy groceries" } // category defaults to "Other"
Список всех задач: возвращает все задачи, отсортированные по новизне.
GET http://localhost:3000/tasks
Фильтр по категории: возвращает только задачи категории Work. Индекс, добавленный ранее, обеспечивает быстроту этого запроса по мере роста данных.
GET http://localhost:3000/tasks?category=Work
Обновление задачи: используйте _id одной из только что созданных задач (его можно скопировать из ответа на POST или из списка). В ответ придёт обновлённая задача. Достаточно отправить только изменяемые поля — частичные обновления остаются простыми.
PUT http://localhost:3000/tasks/<paste-task-id-here>
Content-Type: application/json
{ "completed": true }
Удаление задачи: используйте _id удаляемой задачи. Возвращается 204 No Content. Если попробовать снова получить эту задачу, вернётся 404 с текстом "task not found".
DELETE http://localhost:3000/tasks/<paste-task-id-here>
К этому моменту вы убедились, что все маршруты работают как ожидается. API справляется со своей задачей, значит, можно переходить к созданию интерфейса поверх него.
Шаг 8. Добавляем фронтенд для работы с API
Postman отлично подходит для проверки API, но лучше увидеть, как всё работает на реальном сайте, поэтому добавим во всё это немного фронтенда.
Вместо того чтобы вставлять сюда большие файлы HTML и CSS, код добавлен в этот репозиторий на GitHub. Перейдите туда и скопируйте содержимое файлов index.html, styles.css и favicon.svg в папку вашего проекта public/. Сохраните изменения и откройте localhost:3000. Затем вставьте следующий код в файл app.js:
const form = document.getElementById('task-form');
const titleEl = document.getElementById('title');
const descEl = document.getElementById('description');
const catEl = document.getElementById('category');
const errEl = document.getElementById('form-error');
const listEl = document.getElementById('task-list');
const emptyEl = document.getElementById('empty');
const filterEl = document.getElementById('filter');
const countEl = document.getElementById('task-count');
async function api(method, path, body) {
const res = await fetch(path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
const fetchTasks = (category) =>
api('GET', '/tasks' + (category ? `?category=${encodeURIComponent(category)}` : ''));
const createTask = (body) => api('POST', '/tasks', body);
const updateTask = (id, body) => api('PUT', `/tasks/${id}`, body);
const deleteTask = (id) => api('DELETE', `/tasks/${id}`);
function updateCount(tasks) {
const active = tasks.filter((t) => !t.completed).length;
const total = tasks.length;
if (total === 0) {
countEl.textContent = '';
return;
}
const scope = filterEl.value ? ` in ${filterEl.value}` : '';
countEl.textContent = `${total} tasks${scope} · ${active} active`;
}
function renderTask(task) {
const li = document.createElement('li');
li.className = 'task' + (task.completed ? ' completed' : '');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = task.completed;
checkbox.addEventListener('change', () =>
updateTask(task._id, { completed: checkbox.checked }).then(refresh)
);
const title = document.createElement('div');
title.textContent = task.title;
const badge = document.createElement('span');
badge.textContent = task.category;
const del = document.createElement('button');
del.textContent = 'Delete';
del.onclick = () => deleteTask(task._id).then(refresh);
li.append(checkbox, title, badge, del);
return li;
}
function render(tasks) {
listEl.innerHTML = '';
emptyEl.classList.toggle('hidden', tasks.length > 0);
for (const task of tasks) {
listEl.appendChild(renderTask(task));
}
updateCount(tasks);
}
async function refresh() {
try {
const tasks = await fetchTasks(filterEl.value);
render(tasks);
} catch (e) {
errEl.textContent = e.message;
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errEl.textContent = '';
const title = titleEl.value.trim();
if (!title) {
errEl.textContent = 'Title is required.';
return;
}
try {
await createTask({
title,
description: descEl.value.trim(),
category: catEl.value
});
form.reset();
await refresh();
} catch (e) {
errEl.textContent = e.message;
}
});
filterEl.addEventListener('change', refresh);
refresh();
Здесь, в JavaScript, всё снова связывается с вашим API. Каждая функция соответствует одному из маршрутов, поэтому фронтенд легко читать. Все запросы тоже проходят через единый помощник api(), так что обработка ошибок реализована в одном месте, а не дублируется.
Фильтрация работает через строку запроса вида ?category=Work, что напрямую связывается с логикой бэкенда, которую вы уже добавили. После создания, обновления или удаления задачи приложение снова получает актуальный список, чтобы UI оставался синхронизированным. Счётчик задач — небольшая деталь, но он делает приложение более «живым» во время использования.
Теперь перезапустите сервер командой npm run dev, затем откройте http://localhost:3000 и попробуйте Task Manager. Добавляйте задачи, обновляйте их, переключайтесь между категориями и удаляйте. На этом этапе фронтенд и бэкенд полностью связаны и работают сквозным образом.
Итоги
Поздравляем! Вы с нуля создали полноценное API менеджера задач на Node.js, Express и MongoDB. Вы реализовали валидацию, сделали маршруты компактными и связали всё в рабочую систему. Что ещё важнее — вы остались ближе к реальной модели работы MongoDB, не пряча её за абстракциями. Дальше можно развивать приложение в сторону production‑готовности.
Основные выводы
- Использование middleware и централизованной валидации делает код чистым и предсказуемым.
- Нативный драйвер MongoDB даёт контроль без лишних абстракций.
- Хорошо структурированное API легко расширить до уровня production.
FAQs
Нужен ли Mongoose для создания API с MongoDB?
Нет. В этом руководстве используется нативный драйвер MongoDB, который даёт больше контроля и остаётся лёгким. Если вам нужны абстракции схем, вы можете добавить Mongoose позже.
Как валидировать данные без схемы?
Вы можете централизовать валидацию во вспомогательных функциях (например, buildTaskDocument), чтобы каждый маршрут применял одинаковые правила перед записью в базу данных.
Что произойдёт, если передать некорректный ObjectId?
Middleware отклонит его заранее с ответом 400, предотвращая непонятные ошибки со стороны MongoDB.