Пошук уроків, статей та іншого контенту
Розберемо потокове читання тіла запиту та обробку даних із POST і PUT-запитів.
Тіло запиту — це дані, які клієнт передає серверу. Найчастіше тіло використовують у:
POST — для створення ресурсу або надсилання даних;
PUT — для повного оновлення ресурсу.
У Node.js тіло запиту доступне через об’єкт request. Цей об’єкт є потоком читання (Readable stream), тому дані надходять частинами.
клієнт ── chunk 1 ──> сервер
── chunk 2 ──> сервер
── chunk 3 ──> серверНе можна припускати, що все тіло прийде одним фрагментом. Навіть невеликий JSON може бути розділений на кілька частин.
Найпоширеніший спосіб прочитати тіло — обробити події потоку:
data — надійшла чергова частина даних;
end — усі дані отримано;
error — сталася помилка читання;
aborted — клієнт перервав запит.
Приклад базового читання:
const http = require('node:http');
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
res.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
});
res.end(`Отримано: ${body}`);
});
req.on('error', (error) => {
console.error('Помилка читання запиту:', error);
});
});
server.listen(3000, () => {
console.log('Сервер запущено на http://localhost:3000');
});chunk зазвичай є об’єктом Buffer. Спочатку частини об’єднують через Buffer.concat(), а вже потім перетворюють на рядок.
Це важливо для тексту в кодуванні UTF-8: один символ може бути розділений між двома частинами потоку. Якщо перетворювати кожен chunk на рядок окремо, можна отримати пошкоджені символи.
Сучасніший спосіб — використовувати for await...of. Об’єкт request підтримує асинхронну ітерацію, тому тіло можна читати послідовно:
async function readBody(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}Функція завершиться лише після події end. Її можна використати разом з async-обробником запиту.
Тіло запиту надходить від клієнта, тому не варто безмежно накопичувати його в пам’яті. Зловмисний або помилковий клієнт може надіслати дуже великий запит.
Під час читання потрібно перевіряти загальний розмір:
class BodyTooLargeError extends Error {
constructor() {
super('Тіло запиту перевищує допустимий розмір');
this.name = 'BodyTooLargeError';
}
}
async function readBody(request, maxBytes = 1024 * 1024) {
const chunks = [];
let totalBytes = 0;
for await (const chunk of request) {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
throw new BodyTooLargeError();
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}У прикладі максимальний розмір становить 1 МБ. Межа залежить від призначення endpoint-а:
для невеликих JSON-даних достатньо невеликого ліміту;
для завантаження файлів потрібен інший підхід, оскільки весь файл не варто накопичувати в пам’яті.
Заголовок Content-Length може підказати розмір тіла, але не замінює перевірку під час читання. Запит може використовувати потокову передачу частинами, а клієнт може передати некоректне значення або взагалі не передати цей заголовок.
Тіло HTTP-запиту спочатку є послідовністю байтів. Щоб отримати JSON-об’єкт, потрібно:
прочитати всі частини тіла;
перетворити їх на рядок;
викликати JSON.parse();
обробити помилку некоректного JSON.
Тип JSON позначають заголовком:
Content-Type: application/jsonЗначення може містити параметри, наприклад:
Content-Type: application/json; charset=utf-8Тому під час перевірки зазвичай беруть лише основну частину до крапки з комою.
Наведений сервер обробляє POST і PUT для шляху /messages. Він:
читає тіло потоково;
обмежує його розмір;
підтримує JSON і звичайний текст;
повертає помилку для некоректного JSON;
розрізняє POST і PUT.
const http = require('node:http');
const PORT = 3000;
const MAX_BODY_SIZE = 1024 * 1024;
class BodyTooLargeError extends Error {
constructor() {
super('Тіло запиту перевищує допустимий розмір');
this.name = 'BodyTooLargeError';
}
}
async function readBody(request, maxBytes) {
const chunks = [];
let totalBytes = 0;
for await (const chunk of request) {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
throw new BodyTooLargeError();
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function sendJson(response, statusCode, data) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
});
response.end(JSON.stringify(data));
}
const server = http.createServer(async (request, response) => {
const url = new URL(
request.url,
`http://${request.headers.host || 'localhost'}`
);
if (
url.pathname !== '/messages' ||
!['POST', 'PUT'].includes(request.method)
) {
sendJson(response, 404, {
error: 'Маршрут не знайдено',
});
return;
}
let bodyBuffer;
try {
bodyBuffer = await readBody(request, MAX_BODY_SIZE);
} catch (error) {
if (error instanceof BodyTooLargeError) {
sendJson(response, 413, {
error: error.message,
});
return;
}
console.error('Помилка читання тіла:', error);
sendJson(response, 400, {
error: 'Не вдалося прочитати тіло запиту',
});
return;
}
const contentType = (request.headers['content-type'] || '')
.split(';')[0]
.trim()
.toLowerCase();
let body;
if (contentType === 'application/json') {
try {
body = JSON.parse(bodyBuffer.toString('utf8'));
} catch {
sendJson(response, 400, {
error: 'Тіло запиту містить некоректний JSON',
});
return;
}
} else if (contentType === 'text/plain' || contentType === '') {
body = bodyBuffer.toString('utf8');
} else {
sendJson(response, 415, {
error: 'Непідтримуваний Content-Type',
});
return;
}
const statusCode = request.method === 'POST' ? 201 : 200;
sendJson(response, statusCode, {
method: request.method,
message: 'Тіло запиту успішно отримано',
body,
});
});
server.listen(PORT, () => {
console.log(`Сервер запущено на http://localhost:${PORT}`);
});Запустити сервер можна командою:
node server.jsПриклад POST із JSON:
curl -X POST http://localhost:3000/messages \
-H "Content-Type: application/json" \
-d '{"text":"Привіт","published":true}'Приклад PUT із JSON:
curl -X PUT http://localhost:3000/messages \
-H "Content-Type: application/json" \
-d '{"text":"Оновлений текст","published":false}'Приклад запиту з простим текстом:
curl -X POST http://localhost:3000/messages \
-H "Content-Type: text/plain" \
-d "Звичайний текст"Для POST сервер повертає статус 201, а для PUT — 200. У цьому прикладі дані лише повертаються у відповіді, без збереження в базі даних.
Порожнє тіло не є помилкою на рівні потоку. У такому випадку Buffer.concat(chunks) поверне порожній буфер.
Для тексту результатом буде порожній рядок:
const text = bodyBuffer.toString('utf8');Для JSON порожній рядок не є валідним JSON, тому JSON.parse('') викине помилку. Endpoint має заздалегідь визначити, чи є тіло обов’язковим, і повернути зрозумілу помилку, якщо даних немає.
Наприклад:
if (bodyBuffer.length === 0) {
sendJson(response, 400, {
error: 'Тіло запиту не може бути порожнім',
});
return;
}Клієнт може закрити з’єднання до завершення передачі тіла. У такому разі читання потоку може завершитися помилкою або перериванням.
Обробник має:
не намагатися обробляти неповне тіло як коректні дані;
не записувати неповні дані;
журналювати помилку, якщо це потрібно для діагностики.
Під час використання for await...of помилка потоку буде передана через try...catch.
dataНеправильно:
request.once('data', (chunk) => {
const body = JSON.parse(chunk.toString('utf8'));
});Подія data може спрацювати кілька разів. Перший chunk може містити лише частину JSON.
Потрібно дочекатися завершення потоку і об’єднати всі частини.
JSON.parse() для кожної частиниНеправильно:
request.on('data', (chunk) => {
JSON.parse(chunk.toString('utf8'));
});Окремий фрагмент майже ніколи не є повним JSON-документом. Парсити потрібно лише після отримання всього тіла.
Без ліміту сервер може накопичити надто великий обсяг даних у пам’яті. Завжди визначайте максимальний розмір для endpoint-а.
Content-TypeОдин і той самий набір байтів може бути JSON, звичайним текстом або іншим форматом. Заголовок Content-Type допомагає вибрати правильний спосіб декодування.
Неправильно:
let body = '';
request.on('data', (chunk) => {
body += chunk.toString('utf8');
});Такий код часто працює для простого ASCII-тексту, але може некоректно працювати з багатобайтовими UTF-8 символами, розділеними між частинами потоку. Надійніше спочатку об’єднати Buffer, а потім один раз перетворити його на рядок.
request у Node.js є потоком читання.
Тіло надходить частинами, тому не можна покладатися на один data.
Завершення читання визначає подія end або завершення for await...of.
Частини тіла потрібно об’єднати перед декодуванням.
JSON обробляють після повного читання через JSON.parse().
Content-Type визначає формат переданих даних.
Для захисту сервера потрібно обмежувати максимальний розмір тіла.
Некоректний JSON, перевищення ліміту та перерваний запит мають оброблятися окремо.