REST API на Express собирается из трёх частей: маршрутов для каждого действия над ресурсом (GET, POST, PATCH, DELETE на /api/tasks), middleware, которые разбирают JSON и логируют запросы, и центрального обработчика ошибок с четырьмя аргументами. Входные данные проверяются схемой Zod, а любая ошибка — неверное поле, несуществующая запись, конфликт — превращается в ответ с правильным кодом (400, 404, 409) в едином формате Problem Details. В Express 5 ошибки из async-обработчиков попадают в этот обработчик автоматически.
После материала вы сможете
- реализовать пять 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 г.
Содержание
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, Zod для валидации и supertest для тестов. Тесты запустим встроенным в Node.js раннером node --test, поэтому отдельный тестовый фреймворк не нужен.
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{
"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"
}
}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;
}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
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;
}Обратите внимание: ни в одном обработчике нет try/catch. В Express 5 обработчик, который вернул отклонённый промис или выбросил ошибку внутри async-функции, автоматически передаёт её в next(err) — так написано и в руководстве по миграции, и в разделе об обработке ошибок. В Express 4 такая ошибка терялась, и каждый async-маршрут приходилось оборачивать вручную или подключать отдельный пакет.
$ 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/*'
}Валидация входных данных схемой Zod
Сервер не доверяет ничему, что пришло в запросе: телу, параметрам пути и строки запроса. Проверять поля вручную через typeof быстро становится нечитаемо, поэтому данные описывают схемой. Zod 4 (на дату проверки 4.6.6) задаёт схему кодом, проверяет данные и возвращает их уже в нужном виде — с обрезанными пробелами и значениями по умолчанию.
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();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 должен совпадать с текстовой фразой кода ответа.
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");
};
}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 другой объект с теми же методами.
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);
}
}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}`);
});$ npm start # second instance, port 8080 already in use
> tasks-api@1.0.0 start
> node src/server.js
Cannot start server: EADDRINUSEХранилище в памяти годится для урока и тестов, но не для работы: при перезапуске данные исчезнут, а проверка уникальности названия между «найти» и «создать» не защищена от двух одновременных запросов. В базе данных такие правила закрепляют уникальным индексом, и тогда конфликт ловят по ошибке базы — это тема направления «Базы данных».
Проверка: curl и автотесты
Запустите npm run dev и пройдите сценарий целиком: создать две задачи, получить страницу списка, отметить задачу выполненной, отфильтровать, удалить. Команды — для bash (macOS, Linux, Git Bash в Windows).
$ 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$ 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"}Третий запрос показывает изменение Express 5: тело без Content-Type: application/json не разбирается, и req.body равен undefined (в Express 4 был пустой объект). Схема честно сообщает, что ожидала объект. А лишнее поле id отклонено благодаря z.strictObject.
$ 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. Перед каждым тестом создаётся новое приложение с пустым хранилищем, поэтому тесты не зависят друг от друга.
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");
});
});$ 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Частые ошибки, ограничения и упражнения
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.