Как делать11 октября 2026 г.8 мин чтения
Backend на Node.jsШаг 3 из 8

REST API на Express: CRUD, валидация, ошибки

Коротко

REST API на Express собирается из трёх частей: маршрутов для каждого действия над ресурсом (GET, POST, PATCH, DELETE на /api/tasks), middleware, которые разбирают JSON и логируют запросы, и центрального обработчика ошибок с четырьмя аргументами. Входные данные проверяются схемой Zod, а любая ошибка — неверное поле, несуществующая запись, конфликт — превращается в ответ с правильным кодом (400, 404, 409) в едином формате Problem Details. В Express 5 ошибки из async-обработчиков попадают в этот обработчик автоматически.

Пошаговый урокУровень: НачальныйExpressПрактика: ~1,5–3 ч

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

  • реализовать пять CRUD-эндпоинтов на Express 5 с Router, async-обработчиками и кодами 200, 201, 204
  • валидировать тело, параметры пути и строки запроса схемами Zod и отклонять лишние поля
  • написать центральный обработчик ошибок, который возвращает 400, 404, 409 и 500 в формате Problem Details
  • проверить API через curl и покрыть его тестами node:test + supertest

Проверено на

Node.js 24 LTS (24.18)Express 5.3.0Zod 4.6.6supertest 7.3.1node:test

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Express: приложение, маршруты и middleware
  2. 02Ресурс и маршруты CRUD
  3. 03Валидация входных данных схемой Zod
  4. 04Коды ответов и единый формат ошибок
  5. 05Хранилище: сначала в памяти, потом база данных
  6. 06Проверка: curl и автотесты
  7. 07Частые ошибки, ограничения и упражнения

Express: приложение, маршруты и middleware

В уроке про Node.js мы написали сервер на node:http и упёрлись в ручную работу: сравнение путей, разбор параметров, сборку ответов. Express — минималистичный фреймворк поверх того же node:http, который берёт это на себя. Актуальная мажорная версия — 5: в реестре npm на дату проверки latest — 5.3.0, а ветка 4.x помечена как latest-4. Express 5 требует Node.js 18 или новее; код урока проверен на Node.js 24.18.

Всё приложение Express — это цепочка функций-посредников (middleware). Middleware получает запрос req, ответ res и функцию next: она может ответить сама или передать запрос дальше, вызвав next(). Маршрут — тоже middleware, только привязанный к методу и пути. Если в next передать ошибку, Express пропустит обычные middleware и перейдёт к обработчикам ошибок — функциям с четырьмя аргументами (err, req, res, next).

Журнал запросаexpress.json()Маршруты /api/tasks404 для остальногоОбработчик ошибок
Порядок app.use() — это порядок, в котором запрос проходит цепочку; ошибка с любого шага уходит сразу в последний.

Создадим проект и поставим зависимости: Express, Zod для валидации и supertest для тестов. Тесты запустим встроенным в Node.js раннером node --test, поэтому отдельный тестовый фреймворк не нужен.

bash
mkdir tasks-api
cd tasks-api
npm init -y
npm pkg set type=module
npm pkg set private=true --json
npm pkg delete main scripts.test
npm pkg set scripts.dev="node --watch src/server.js" scripts.start="node src/server.js" scripts.test="node --test"
npm install express zod
npm install -D supertest
Проект tasks-api: ES-модули, скрипты dev/start/test
package.json
{
  "name": "tasks-api",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "dev": "node --watch src/server.js",
    "start": "node src/server.js",
    "test": "node --test"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "private": true,
  "dependencies": {
    "express": "^5.3.0",
    "zod": "^4.6.6"
  },
  "devDependencies": {
    "supertest": "^7.3.1"
  }
}
Реальный package.json после команд выше
src/app.js
import express from "express";
import { errorHandler, notFound } from "./errors.js";
import { tasksRouter } from "./tasks/router.js";

export function createApp({ store, logger = console }) {
  const app = express();
  app.disable("x-powered-by");

  app.use((req, res, next) => {
    const started = performance.now();
    res.on("finish", () => {
      const ms = (performance.now() - started).toFixed(1);
      logger.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
    });
    next();
  });
  app.use(express.json({ limit: "10kb" }));

  app.get("/health", (req, res) => res.json({ status: "ok" }));
  app.use("/api/tasks", tasksRouter(store));

  app.use(notFound);
  app.use(errorHandler(logger));
  return app;
}
Сборка приложения: журнал, разбор JSON с лимитом 10 КБ, маршруты, 404 и центральный обработчик ошибок
  • createApp получает хранилище и логгер параметрами, а не импортирует их. Так тесты подставят свои — пустой логгер и сломанное хранилище — без запуска сервера
  • express.json() разбирает тело только при Content-Type: application/json. По умолчанию лимит тела — 100 КБ; для API задач хватит 10 КБ, а меньший лимит — меньше поводов для атаки большими телами
  • app.disable("x-powered-by") убирает заголовок, который сообщает всем, что сервер работает на Express
  • Обработчик 404 и обработчик ошибок подключаются последними: Express проходит цепочку по порядку

Ресурс и маршруты CRUD

REST-ресурс — сущность со своим адресом. У нас это задачи: коллекция /api/tasks и отдельная задача /api/tasks/:id. CRUD (Create, Read, Update, Delete) — четыре действия над ресурсом, которые ложатся на методы HTTP. Получается пять эндпоинтов:

GET /api/tasks

Действие
Список с фильтром done и пагинацией limit/offset
Успех
200
Ошибки
400 — неверные параметры

GET /api/tasks/:id

Действие
Одна задача
Успех
200
Ошибки
400 — id не число, 404

POST /api/tasks

Действие
Создать задачу
Успех
201 + Location
Ошибки
400, 409 — такое название уже есть

PATCH /api/tasks/:id

Действие
Частично изменить
Успех
200
Ошибки
400, 404, 409

DELETE /api/tasks/:id

Действие
Удалить
Успех
204 без тела
Ошибки
400, 404
src/tasks/router.js
import { Router } from "express";
import { HttpError } from "../errors.js";
import { createTaskSchema, idSchema, listQuerySchema, updateTaskSchema } from "./schemas.js";

export function tasksRouter(store) {
  const router = Router();

  async function findTask(rawId) {
    const parsed = idSchema.safeParse(rawId);
    if (!parsed.success) throw new HttpError(400, "Task id must be a positive integer");
    const task = await store.get(parsed.data);
    if (!task) throw new HttpError(404, `Task ${parsed.data} not found`);
    return task;
  }

  async function assertTitleIsFree(title, exceptId) {
    const existing = await store.findByTitle(title);
    if (existing && existing.id !== exceptId) {
      throw new HttpError(409, `Task "${existing.title}" already exists (id ${existing.id})`);
    }
  }

  router.get("/", async (req, res) => {
    const query = listQuerySchema.parse(req.query);
    const { items, total } = await store.list(query);
    res.json({ data: items, meta: { total, limit: query.limit, offset: query.offset } });
  });

  router.get("/:id", async (req, res) => {
    res.json(await findTask(req.params.id));
  });

  router.post("/", async (req, res) => {
    const input = createTaskSchema.parse(req.body);
    await assertTitleIsFree(input.title);
    const task = await store.create(input);
    res.status(201).location(`${req.baseUrl}/${task.id}`).json(task);
  });

  router.patch("/:id", async (req, res) => {
    const task = await findTask(req.params.id);
    const patch = updateTaskSchema.parse(req.body);
    if (patch.title) await assertTitleIsFree(patch.title, task.id);
    res.json(await store.update(task.id, patch));
  });

  router.delete("/:id", async (req, res) => {
    const task = await findTask(req.params.id);
    await store.remove(task.id);
    res.status(204).end();
  });

  return router;
}
Маршруты задач: обработчики async, ошибки выбрасываются, а не отправляются вручную

Обратите внимание: ни в одном обработчике нет try/catch. В Express 5 обработчик, который вернул отклонённый промис или выбросил ошибку внутри async-функции, автоматически передаёт её в next(err) — так написано и в руководстве по миграции, и в разделе об обработке ошибок. В Express 4 такая ошибка терялась, и каждый async-маршрут приходилось оборачивать вручную или подключать отдельный пакет.

terminal
$ node wildcard.js   # app.get("/api/*", ...)
…/node_modules/path-to-regexp/dist/index.js:108
                    throw new PathError(`Missing parameter name at index ${index}`, str);
                          ^

PathError [TypeError]: Missing parameter name at index 6: /api/*; visit https://git.new/pathToRegexpError for info
  originalPath: '/api/*'
}
Реальная ошибка Express 5.3 при пути /api/* в стиле Express 4: приложение падает при регистрации маршрута

Валидация входных данных схемой Zod

Сервер не доверяет ничему, что пришло в запросе: телу, параметрам пути и строки запроса. Проверять поля вручную через typeof быстро становится нечитаемо, поэтому данные описывают схемой. Zod 4 (на дату проверки 4.6.6) задаёт схему кодом, проверяет данные и возвращает их уже в нужном виде — с обрезанными пробелами и значениями по умолчанию.

src/tasks/schemas.js
import * as z from "zod";

const title = z.string().trim().min(1, "Title is required").max(200);
const priority = z.enum(["low", "normal", "high"]);

export const createTaskSchema = z.strictObject({
  title,
  priority: priority.default("normal"),
  done: z.boolean().default(false),
});

export const updateTaskSchema = z
  .strictObject({
    title: title.optional(),
    priority: priority.optional(),
    done: z.boolean().optional(),
  })
  .refine((data) => Object.keys(data).length > 0, "Provide at least one field to update");

export const listQuerySchema = z.object({
  done: z.enum(["true", "false"]).transform((value) => value === "true").optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  offset: z.coerce.number().int().min(0).default(0),
});

export const idSchema = z.coerce.number().int().positive();
Схемы: создание, частичное изменение, параметры списка и id

z.strictObject

Что делает
Отклоняет неизвестные поля
Зачем
Клиент не подсунет id или createdAt — защита от массового присваивания (mass assignment)

.trim().min(1)

Что делает
Обрезает пробелы, потом проверяет длину
Зачем
Название из одних пробелов не пройдёт

.default("normal")

Что делает
Подставляет значение, если поле не пришло
Зачем
Обработчик всегда получает полный объект

z.coerce.number()

Что делает
Приводит строку к числу
Зачем
Параметры пути и строки запроса — всегда строки

.refine(...)

Что делает
Произвольное правило для всего объекта
Зачем
PATCH с пустым телом — ошибка, а не тихий успех

У схем два метода проверки. parse возвращает данные или выбрасывает ZodError — его удобно использовать в обработчиках: ошибка уйдёт в центральный обработчик, и он превратит её в ответ 400. safeParse не бросает исключение, а возвращает объект { success, data | error } — им мы проверяем id, чтобы ответить своим сообщением. Схема изменения описана отдельно, а не через .partial() от схемы создания, чтобы значения по умолчанию не перезаписали существующие поля при PATCH.

Коды ответов и единый формат ошибок

Клиенту API важно получать ошибки в одном предсказуемом формате: тогда фронтенд пишет одну функцию разбора, а не угадывает форму ответа у каждого маршрута. Стандарт для этого — Problem Details, RFC 9457. Ответ имеет тип application/problem+json и поля type, title, status, detail, instance; разрешены и свои поля-расширения — у нас это errors со списком ошибок по полям. Если type не указан, он по стандарту равен about:blank, и тогда title должен совпадать с текстовой фразой кода ответа.

src/errors.js
import { STATUS_CODES } from "node:http";
import * as z from "zod";

export class HttpError extends Error {
  constructor(status, detail) {
    super(detail);
    this.status = status;
  }
}

function sendProblem(res, status, detail, extra = {}) {
  res
    .status(status)
    .type("application/problem+json")
    .json({ title: STATUS_CODES[status], status, detail, ...extra });
}

export function notFound(req, res, next) {
  next(new HttpError(404, `Route ${req.method} ${req.originalUrl} not found`));
}

export function errorHandler(logger) {
  return (err, req, res, next) => {
    if (res.headersSent) return next(err);

    if (err instanceof z.ZodError) {
      const errors = err.issues.map((issue) => ({
        path: issue.path.join(".") || "body",
        message: issue.message,
      }));
      return sendProblem(res, 400, "Request validation failed", { errors });
    }
    if (err instanceof HttpError) return sendProblem(res, err.status, err.message);
    if (err.type === "entity.parse.failed") {
      return sendProblem(res, 400, "Request body is not valid JSON");
    }
    if (err.type === "entity.too.large") {
      return sendProblem(res, 413, "Request body is too large");
    }

    logger.error(err);
    sendProblem(res, 500, "Unexpected server error");
  };
}
Центральный обработчик: ZodError → 400, HttpError → его код, ошибки разбора тела → 400 и 413, всё остальное → 500 без деталей

200 OK

Когда в этом API
Чтение и изменение прошли успешно
Тело
Задача или список

201 Created

Когда в этом API
Задача создана
Тело
Задача + заголовок Location

204 No Content

Когда в этом API
Задача удалена
Тело
Пусто

400 Bad Request

Когда в этом API
Битый JSON, неверные поля или параметры
Тело
Problem Details + errors

404 Not Found

Когда в этом API
Нет задачи или маршрута
Тело
Problem Details

409 Conflict

Когда в этом API
Название уже занято
Тело
Problem Details

413 Payload Too Large

Когда в этом API
Тело больше лимита express.json
Тело
Problem Details

500 Internal Server Error

Когда в этом API
Непредвиденная ошибка
Тело
Problem Details без подробностей

Для неверных полей некоторые API отвечают 422 Unprocessable Content — так было и в нашем уроке про HTTP и JSON, и так сделан пример в самом RFC 9457. Оба варианта встречаются на практике; важно выбрать один и описать его в документации API. Подробно соглашения — формат ошибок, пагинация, версионирование — разбираются в уроке о проектировании API, здесь мы применяем их в минимальном виде.

Хранилище: сначала в памяти, потом база данных

Чтобы сосредоточиться на HTTP, задачи пока хранятся в Map в памяти процесса. Важная деталь — методы хранилища асинхронные, хотя Map работает синхронно. Когда хранилище заменят на PostgreSQL, его методы и так станут асинхронными, и маршруты не придётся переписывать: достаточно передать в createApp другой объект с теми же методами.

src/tasks/store.js
export class TaskStore {
  #tasks = new Map();
  #nextId = 1;

  async list({ done, limit, offset }) {
    let items = [...this.#tasks.values()];
    if (done !== undefined) items = items.filter((task) => task.done === done);
    return { items: items.slice(offset, offset + limit), total: items.length };
  }

  async get(id) {
    return this.#tasks.get(id) ?? null;
  }

  async findByTitle(title) {
    const wanted = title.toLowerCase();
    for (const task of this.#tasks.values()) {
      if (task.title.toLowerCase() === wanted) return task;
    }
    return null;
  }

  async create(data) {
    const task = { id: this.#nextId++, ...data, createdAt: new Date().toISOString() };
    this.#tasks.set(task.id, task);
    return task;
  }

  async update(id, patch) {
    const task = { ...this.#tasks.get(id), ...patch };
    this.#tasks.set(id, task);
    return task;
  }

  async remove(id) {
    this.#tasks.delete(id);
  }
}
Хранилище в памяти: данные пропадают при перезапуске, а при двух копиях сервера у каждой свои
src/server.js
import { createApp } from "./app.js";
import { TaskStore } from "./tasks/store.js";

const port = Number(process.env.PORT ?? 8080);
const app = createApp({ store: new TaskStore() });

app.listen(port, "127.0.0.1", (error) => {
  if (error) {
    console.error(`Cannot start server: ${error.code}`);
    process.exit(1);
  }
  console.log(`Tasks API: http://127.0.0.1:${port}`);
});
Точка входа: в Express 5 ошибка запуска, например занятый порт, приходит в колбэк app.listen
terminal
$ npm start   # second instance, port 8080 already in use

> tasks-api@1.0.0 start
> node src/server.js

Cannot start server: EADDRINUSE
Реальный запуск второго экземпляра: EADDRINUSE пойман в колбэке, процесс завершился с кодом 1

Хранилище в памяти годится для урока и тестов, но не для работы: при перезапуске данные исчезнут, а проверка уникальности названия между «найти» и «создать» не защищена от двух одновременных запросов. В базе данных такие правила закрепляют уникальным индексом, и тогда конфликт ловят по ошибке базы — это тема направления «Базы данных».

Проверка: curl и автотесты

Запустите npm run dev и пройдите сценарий целиком: создать две задачи, получить страницу списка, отметить задачу выполненной, отфильтровать, удалить. Команды — для bash (macOS, Linux, Git Bash в Windows).

terminal
$ curl -si -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "Call the client", "priority": "high"}'
HTTP/1.1 201 Created
Location: /api/tasks/1
Content-Type: application/json; charset=utf-8
Content-Length: 104
ETag: W/"68-Oz2mnFVsGJlVZ4AF7cp/JbdJJXQ"
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"title":"Call the client","priority":"high","done":false,"createdAt":"2026-10-11T23:23:02.359Z"}

$ curl -s -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "Prepare the contract"}'
{"id":2,"title":"Prepare the contract","priority":"normal","done":false,"createdAt":"2026-10-11T23:23:02.388Z"}

$ curl -s 'http://127.0.0.1:8080/api/tasks?limit=1'
{"data":[{"id":1,"title":"Call the client","priority":"high","done":false,"createdAt":"2026-10-11T23:23:02.359Z"}],"meta":{"total":2,"limit":1,"offset":0}}

$ curl -s -X PATCH http://127.0.0.1:8080/api/tasks/1 -H 'Content-Type: application/json' -d '{"done": true}'
{"id":1,"title":"Call the client","priority":"high","done":true,"createdAt":"2026-10-11T23:23:02.359Z"}

$ curl -s 'http://127.0.0.1:8080/api/tasks?done=true'
{"data":[{"id":1,"title":"Call the client","priority":"high","done":true,"createdAt":"2026-10-11T23:23:02.359Z"}],"meta":{"total":1,"limit":20,"offset":0}}

$ curl -si -X DELETE http://127.0.0.1:8080/api/tasks/2
HTTP/1.1 204 No Content
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Успешный сценарий: 201 с Location, пагинация в meta, PATCH, фильтр done=true и 204 без тела
terminal
$ curl -si -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "  ", "priority": "urgent", "id": 7}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
Content-Length: 276
ETag: W/"114-ReqTpYGYyIiQxhc06Nl/h2R0V8Y"
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"title","message":"Title is required"},{"path":"priority","message":"Invalid option: expected one of \"low\"|\"normal\"|\"high\""},{"path":"body","message":"Unrecognized key: \"id\""}]}

$ curl -s -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": '
{"title":"Bad Request","status":400,"detail":"Request body is not valid JSON"}

$ curl -s -X POST http://127.0.0.1:8080/api/tasks -d 'title=Call'
{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"body","message":"Invalid input: expected object, received undefined"}]}

$ curl -si -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "call the client"}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8
Content-Length: 91
ETag: W/"5b-JwoMGqDcMhfgj8sjRVM1/SweD8Y"
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"title":"Conflict","status":409,"detail":"Task \"Call the client\" already exists (id 1)"}

$ curl -s http://127.0.0.1:8080/api/tasks/2
{"title":"Not Found","status":404,"detail":"Task 2 not found"}

$ curl -s http://127.0.0.1:8080/api/tasks/abc
{"title":"Bad Request","status":400,"detail":"Task id must be a positive integer"}

$ curl -s -X PATCH http://127.0.0.1:8080/api/tasks/1 -H 'Content-Type: application/json' -d '{}'
{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"body","message":"Provide at least one field to update"}]}

$ curl -s 'http://127.0.0.1:8080/api/tasks?limit=500'
{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"limit","message":"Too big: expected number to be <=100"}]}

$ curl -s http://127.0.0.1:8080/api/projects
{"title":"Not Found","status":404,"detail":"Route GET /api/projects not found"}
Ошибки в едином формате: неверные поля и лишний id, битый JSON, тело без Content-Type, дубль, несуществующая задача, неверный id, пустой PATCH, limit вне диапазона и неизвестный маршрут

Третий запрос показывает изменение Express 5: тело без Content-Type: application/json не разбирается, и req.body равен undefined (в Express 4 был пустой объект). Схема честно сообщает, что ожидала объект. А лишнее поле id отклонено благодаря z.strictObject.

terminal
$ curl -s -X POST http://127.0.0.1:8080/api/tasks -H "Content-Type: application/json" --data-binary @task.json
{"id":3,"title":"Позвонить клиенту","priority":"normal","done":false,"createdAt":"2026-10-11T23:23:02.780Z"}
Тело из файла: кириллица дошла без искажений

Ручная проверка не повторяется сама. Автотест закрепляет поведение: supertest принимает приложение Express, сам поднимает его на свободном порту и отправляет запросы, а node:test и node:assert встроены в Node.js. Перед каждым тестом создаётся новое приложение с пустым хранилищем, поэтому тесты не зависят друг от друга.

test/tasks.test.js
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import request from "supertest";
import { createApp } from "../src/app.js";
import { TaskStore } from "../src/tasks/store.js";

const silent = { log() {}, error() {} };
let app;

beforeEach(() => {
  app = createApp({ store: new TaskStore(), logger: silent });
});

describe("POST /api/tasks", () => {
  it("creates a task: 201, Location and defaults", async () => {
    const res = await request(app).post("/api/tasks").send({ title: "  Call the client " });
    assert.equal(res.status, 201);
    assert.equal(res.headers.location, "/api/tasks/1");
    assert.equal(res.body.title, "Call the client");
    assert.equal(res.body.priority, "normal");
    assert.equal(res.body.done, false);
  });

  it("rejects invalid fields with 400 and a problem+json body", async () => {
    const res = await request(app).post("/api/tasks").send({ title: "", priority: "urgent" });
    assert.equal(res.status, 400);
    assert.match(res.headers["content-type"], /^application\/problem\+json/);
    assert.deepEqual(res.body.errors.map((e) => e.path), ["title", "priority"]);
  });

  it("rejects malformed JSON with 400", async () => {
    const res = await request(app)
      .post("/api/tasks")
      .set("Content-Type", "application/json")
      .send('{"title": ');
    assert.equal(res.status, 400);
    assert.equal(res.body.detail, "Request body is not valid JSON");
  });

  it("returns 409 for a duplicate title (case-insensitive)", async () => {
    await request(app).post("/api/tasks").send({ title: "Call the client" });
    const res = await request(app).post("/api/tasks").send({ title: "CALL THE CLIENT" });
    assert.equal(res.status, 409);
  });
});

describe("GET, PATCH and DELETE /api/tasks/:id", () => {
  beforeEach(async () => {
    await request(app).post("/api/tasks").send({ title: "Call the client" });
    await request(app).post("/api/tasks").send({ title: "Prepare the contract" });
  });

  it("lists tasks with pagination and a filter", async () => {
    const page = await request(app).get("/api/tasks?limit=1&offset=1");
    assert.equal(page.status, 200);
    assert.deepEqual(page.body.meta, { total: 2, limit: 1, offset: 1 });
    assert.equal(page.body.data[0].title, "Prepare the contract");

    const done = await request(app).get("/api/tasks?done=true");
    assert.equal(done.body.meta.total, 0);
  });

  it("updates a task partially", async () => {
    const res = await request(app).patch("/api/tasks/1").send({ done: true });
    assert.equal(res.status, 200);
    assert.equal(res.body.done, true);
    assert.equal(res.body.title, "Call the client");
  });

  it("rejects an empty update and a taken title", async () => {
    const empty = await request(app).patch("/api/tasks/1").send({});
    assert.equal(empty.status, 400);
    const taken = await request(app).patch("/api/tasks/1").send({ title: "Prepare the contract" });
    assert.equal(taken.status, 409);
  });

  it("deletes a task: 204, then 404", async () => {
    const first = await request(app).delete("/api/tasks/2");
    assert.equal(first.status, 204);
    const second = await request(app).delete("/api/tasks/2");
    assert.equal(second.status, 404);
  });

  it("returns 400 for a non-numeric id and 404 for an unknown route", async () => {
    assert.equal((await request(app).get("/api/tasks/abc")).status, 400);
    assert.equal((await request(app).get("/api/projects")).status, 404);
  });
});

describe("unexpected errors", () => {
  it("answers 500 without leaking details and logs the error", async () => {
    const logged = [];
    const brokenStore = {
      async list() {
        throw new Error("connection to database lost");
      },
    };
    const broken = createApp({ store: brokenStore, logger: { log() {}, error: (e) => logged.push(e) } });
    const res = await request(broken).get("/api/tasks");
    assert.equal(res.status, 500);
    assert.equal(res.body.detail, "Unexpected server error");
    assert.doesNotMatch(JSON.stringify(res.body), /database/);
    assert.equal(logged[0].message, "connection to database lost");
  });
});
10 тестов: создание, валидация, битый JSON, 409, пагинация, PATCH, DELETE, 400/404 и 500 без утечки деталей
terminal
$ npm test

> tasks-api@1.0.0 test
> node --test

▶ POST /api/tasks
  ✔ creates a task: 201, Location and defaults (34.175ms)
  ✔ rejects invalid fields with 400 and a problem+json body (9.5175ms)
  ✔ rejects malformed JSON with 400 (4.2543ms)
  ✔ returns 409 for a duplicate title (case-insensitive) (6.3681ms)
✔ POST /api/tasks (55.2454ms)
▶ GET, PATCH and DELETE /api/tasks/:id
  ✔ lists tasks with pagination and a filter (11.4126ms)
  ✔ updates a task partially (7.5939ms)
  ✔ rejects an empty update and a taken title (8.3594ms)
  ✔ deletes a task: 204, then 404 (7.6509ms)
  ✔ returns 400 for a non-numeric id and 404 for an unknown route (7.2247ms)
✔ GET, PATCH and DELETE /api/tasks/:id (42.6496ms)
▶ unexpected errors
  ✔ answers 500 without leaking details and logs the error (2.7755ms)
✔ unexpected errors (2.8955ms)
ℹ tests 10
ℹ suites 3
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 371.4657
Реальный вывод node --test на Node.js 24.18

Частые ошибки, ограничения и упражнения

req.body равен undefined

Причина
Не подключён express.json() или клиент не отправил Content-Type: application/json
Исправление
Подключить парсер до маршрутов; проверять тело схемой

Ошибка Missing parameter name при старте

Причина
Шаблон пути из Express 4: /*, :param?
Исправление
/*splat, {/:param} — синтаксис path-to-regexp v8

Ответ в формате HTML со стеком вместо JSON

Причина
Свой обработчик ошибок не сработал: у него три аргумента или он подключён до маршрутов
Исправление
Четыре аргумента (err, req, res, next), подключать последним

res.json(obj, 201) отвечает 200, а не 201

Причина
Сигнатура Express 4 не поддерживается: второй аргумент молча игнорируется
Исправление
res.status(201).json(obj)

Запрос «висит» без ответа

Причина
Middleware не вызвал next() и не отправил ответ
Исправление
Каждая ветка middleware должна заканчиваться ответом или next()

Ошибка в колбэке setTimeout или старого API роняет процесс

Причина
Express ловит только синхронные ошибки и отклонённые промисы обработчика
Исправление
Перехватить внутри колбэка и передать в next(err)

Этот API — учебный. Перед продакшеном ему не хватает аутентификации и проверки прав, ограничения частоты запросов, настоящей базы данных с уникальным индексом, заголовков безопасности и структурированных логов. Руководство Express по безопасности рекомендует, в частности, держать зависимости обновлёнными и не раскрывать стек технологий. Если проекту нужна строгая структура модулей и внедрение зависимостей из коробки, посмотрите NestJS — по умолчанию он работает поверх того же Express.

Как проверено6 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    package.json + установка (express 5.3.0, zod 4.6.6, supertest 7.3.1)

    команды выполнены во временном проекте scratchpad/kb/verify/express-rest-api/tasks-api, npm 11.16, Node.js 24.18.0; npm ls подтвердил версии

  • запущено, работает

    src/app.js, src/server.js, src/errors.js, src/tasks/{router,schemas,store}.js

    node --check; сервер запущен, 15 curl-запросов — реальный вывод вставлен в статью сборочным скриптом из файлов вывода; дополнительно 413 при теле > 10 КБ (fetch); второй экземпляр — EADDRINUSE в колбэке app.listen, exit 1

  • запущено, работает

    test/tasks.test.js

    npm test (node --test + supertest): 10 тестов, pass 10, fail 0 — вывод в статье реальный

  • запущено, работает

    Ошибка path-to-regexp для /api/*

    отдельный скрипт с app.get("/api/*") на Express 5.3.0 — PathError «Missing parameter name at index 6»; путь в выводе сокращён

  • запущено, работает

    Утверждения таблицы частых ошибок и раздела о схемах

    проверочный скрипт: res.json(obj, 201) отвечает 200 (второй аргумент игнорируется); /tasks{/:id} совпадает с /tasks и /tasks/5; createSchema.partial().parse({done:true}) подставляет priority "normal" — поэтому схема PATCH описана отдельно

  • запущено, работает

    Решения упражнений repeat и modify

    копия проекта: с фильтром q и тестом search.test.js — 11/11 pass; без ветки ZodError в errorHandler — падают ровно 2 теста (400 → 500)

  • не запускалось

    Упражнение apply (/api/leads)

    решение не приводится — только подсказка по схеме; код подсказки не исполнялся

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.