Пошук уроків, статей та іншого контенту
Створимо JSON API з коректними заголовками, серіалізацією даних, статусами та обробкою помилок.
JSON API — це HTTP API, у якому дані запитів і відповідей передаються у форматі JSON.
Клієнт надсилає HTTP-запит:
методом GET, POST, PUT, DELETE тощо;
із заголовками;
за певним URL;
іноді з JSON у тілі запиту.
Сервер повертає:
HTTP-статус;
заголовки;
JSON у тілі відповіді.
Наприклад, відповідь API може мати такий вигляд:
{
"id": 1,
"name": "Keyboard",
"price": 49.99
}У Node.js об’єкт JavaScript перетворюється на JSON за допомогою JSON.stringify(), а JSON із тіла запиту — на JavaScript-значення за допомогою JSON.parse().
Для JSON-відповіді потрібно вказати заголовок:
Content-Type: application/json; charset=utf-8Він повідомляє клієнту, що тіло відповіді містить JSON у кодуванні UTF-8.
У Node.js заголовки можна встановити через response.setHeader():
response.setHeader("Content-Type", "application/json; charset=utf-8");Зручно створити окрему функцію для всіх JSON-відповідей:
function sendJson(response, statusCode, data) {
const body = JSON.stringify(data);
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body)
});
response.end(body);
}JSON.stringify() повертає рядок, тому довжину тіла потрібно обчислювати для вже серіалізованого рядка. Buffer.byteLength() коректно враховує UTF-8-символи, зокрема українські літери.
Статус має описувати результат операції:
200 OK — успішне отримання або оновлення даних;
201 Created — ресурс успішно створено;
400 Bad Request — некоректний запит або JSON;
404 Not Found — ресурс або маршрут не знайдено;
405 Method Not Allowed — маршрут існує, але HTTP-метод не підтримується;
500 Internal Server Error — непередбачена помилка на сервері.
Недостатньо завжди повертати 200. Клієнт повинен мати змогу визначити результат операції за HTTP-статусом.
Нижче наведено повний сервер без сторонніх бібліотек. Він підтримує:
GET /api/products;
GET /api/products/:id;
POST /api/products;
коректні JSON-заголовки;
серіалізацію відповідей;
статуси 200, 201, 400, 404, 405 і 500;
обробку некоректного JSON.
const http = require("node:http");
const PORT = 3000;
const MAX_BODY_SIZE = 1_000_000;
let nextProductId = 3;
const products = [
{ id: 1, name: "Keyboard", price: 49.99 },
{ id: 2, name: "Mouse", price: 24.5 }
];
class HttpError extends Error {
constructor(statusCode, code, message) {
super(message);
this.statusCode = statusCode;
this.code = code;
}
}
function sendJson(response, statusCode, data, extraHeaders = {}) {
const body = JSON.stringify(data);
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
...extraHeaders
});
response.end(body);
}
function sendError(response, error) {
if (error instanceof HttpError) {
sendJson(response, error.statusCode, {
error: {
code: error.code,
message: error.message
}
});
return;
}
console.error(error);
sendJson(response, 500, {
error: {
code: "INTERNAL_SERVER_ERROR",
message: "Внутрішня помилка сервера"
}
});
}
function readJson(request) {
return new Promise((resolve, reject) => {
let body = "";
let settled = false;
request.setEncoding("utf8");
request.on("data", (chunk) => {
if (settled) {
return;
}
body += chunk;
if (Buffer.byteLength(body) > MAX_BODY_SIZE) {
settled = true;
request.resume();
reject(
new HttpError(
413,
"PAYLOAD_TOO_LARGE",
"Тіло запиту перевищує допустимий розмір"
)
);
}
});
request.on("end", () => {
if (settled) {
return;
}
if (body.trim() === "") {
reject(
new HttpError(
400,
"EMPTY_BODY",
"Тіло запиту не може бути порожнім"
)
);
return;
}
try {
resolve(JSON.parse(body));
} catch {
reject(
new HttpError(
400,
"INVALID_JSON",
"Тіло запиту має містити коректний JSON"
)
);
}
});
request.on("error", (error) => {
if (!settled) {
reject(error);
}
});
});
}
function findProductById(id) {
return products.find((product) => product.id === id);
}
const server = http.createServer(async (request, response) => {
try {
const url = new URL(
request.url,
`http://${request.headers.host || "localhost"}`
);
const pathParts = url.pathname.split("/").filter(Boolean);
const isProductsRoute =
pathParts.length >= 2 &&
pathParts[0] === "api" &&
pathParts[1] === "products";
if (!isProductsRoute) {
throw new HttpError(
404,
"ROUTE_NOT_FOUND",
"Маршрут не знайдено"
);
}
if (pathParts.length === 2 && request.method === "GET") {
sendJson(response, 200, {
data: products
});
return;
}
if (pathParts.length === 3 && request.method === "GET") {
const productId = Number(pathParts[2]);
if (!Number.isInteger(productId)) {
throw new HttpError(
400,
"INVALID_ID",
"Ідентифікатор товару має бути цілим числом"
);
}
const product = findProductById(productId);
if (!product) {
throw new HttpError(
404,
"PRODUCT_NOT_FOUND",
"Товар не знайдено"
);
}
sendJson(response, 200, {
data: product
});
return;
}
if (pathParts.length === 2 && request.method === "POST") {
const contentType = request.headers["content-type"] || "";
if (!contentType.startsWith("application/json")) {
throw new HttpError(
415,
"UNSUPPORTED_MEDIA_TYPE",
"Для цього маршруту потрібен Content-Type application/json"
);
}
const input = await readJson(request);
if (
typeof input.name !== "string" ||
input.name.trim() === ""
) {
throw new HttpError(
400,
"INVALID_NAME",
"Поле name має бути непорожнім рядком"
);
}
if (
typeof input.price !== "number" ||
!Number.isFinite(input.price) ||
input.price < 0
) {
throw new HttpError(
400,
"INVALID_PRICE",
"Поле price має бути невід’ємним числом"
);
}
const product = {
id: nextProductId++,
name: input.name.trim(),
price: input.price
};
products.push(product);
sendJson(response, 201, {
data: product
});
return;
}
if (pathParts.length === 2) {
response.setHeader("Allow", "GET, POST");
throw new HttpError(
405,
"METHOD_NOT_ALLOWED",
"HTTP-метод не підтримується для цього маршруту"
);
}
throw new HttpError(
404,
"ROUTE_NOT_FOUND",
"Маршрут не знайдено"
);
} catch (error) {
sendError(response, error);
}
});
server.listen(PORT, () => {
console.log(`JSON API запущено на http://localhost:${PORT}`);
});Збережіть код у файл server.js і запустіть:
node server.jsСервер буде доступний за адресою http://localhost:3000.
curl -i http://localhost:3000/api/productsПриклад відповіді:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"data":[{"id":1,"name":"Keyboard","price":49.99},{"id":2,"name":"Mouse","price":24.5}]}curl -i http://localhost:3000/api/products/1Сервер повертає статус 200 і JSON об’єкта.
Якщо товару немає:
curl -i http://localhost:3000/api/products/99Відповідь матиме статус 404:
{
"error": {
"code": "PRODUCT_NOT_FOUND",
"message": "Товар не знайдено"
}
}Для POST потрібно передати правильний Content-Type і JSON у тілі запиту:
curl -i \
-X POST \
-H "Content-Type: application/json" \
-d '{"name":"Monitor","price":199.99}' \
http://localhost:3000/api/productsУ разі успіху сервер поверне 201 Created:
{
"data": {
"id": 3,
"name": "Monitor",
"price": 199.99
}
}curl -i \
-X POST \
-H "Content-Type: application/json" \
-d '{"name":"Monitor",' \
http://localhost:3000/api/productsСервер не повинен аварійно завершувати роботу. Він поверне 400 Bad Request із описом помилки.
У прикладі всі помилки мають однакову структуру:
{
"error": {
"code": "INVALID_PRICE",
"message": "Поле price має бути невід’ємним числом"
}
}Єдина структура спрощує роботу клієнта:
code можна використовувати в програмній логіці;
message призначене для пояснення помилки;
HTTP-статус показує загальну категорію результату.
Не варто повертати клієнту необроблений об’єкт помилки або стек викликів. Такі дані можуть розкрити внутрішню структуру сервера.
HTTP передає текстові або бінарні дані, а об’єкти JavaScript потрібно серіалізувати:
const product = {
id: 1,
name: "Keyboard",
price: 49.99
};
const json = JSON.stringify(product);
console.log(json);
// {"id":1,"name":"Keyboard","price":49.99}Зворотна операція:
const input = '{"name":"Monitor","price":199.99}';
const product = JSON.parse(input);
console.log(product.name);
// MonitorВажливо пам’ятати:
JSON.stringify() може викинути помилку для циклічних структур;
JSON.parse() викидає помилку, якщо рядок не є коректним JSON;
дані від клієнта потрібно перевіряти після парсингу;
сам факт успішного JSON.parse() не означає, що дані мають правильну структуру.
Наприклад, цей JSON синтаксично коректний:
{
"price": "дорого"
}Але він не відповідає очікуваній структурі товару, тому сервер має відхилити його зі статусом 400.
Content-TypeЯкщо сервер повертає JSON без заголовка Content-Type, клієнту доводиться здогадуватися про формат даних.
Потрібно явно вказувати:
Content-Type: application/json; charset=utf-8Цей код повертає не JSON-об’єкт, а звичайний текст:
response.end({ message: "OK" });Об’єкт потрібно спочатку серіалізувати:
response.end(JSON.stringify({ message: "OK" }));200Помилка, повернена зі статусом 200, виглядає для HTTP-клієнта як успішна операція. Статус має відповідати результату:
sendJson(response, 404, {
error: {
code: "NOT_FOUND",
message: "Ресурс не знайдено"
}
});JSON.parse()Такий код може завершити обробник винятком:
const data = JSON.parse(body);Парсинг зовнішніх даних потрібно виконувати в try...catch і повертати клієнту зрозумілий статус 400.
JSON може бути синтаксично правильним, але містити неправильні значення. Перед створенням ресурсу потрібно перевірити типи, обов’язкові поля та допустимі значення.
Не слід відправляти клієнту error.stack або повний текст внутрішньої помилки. Для клієнта достатньо загального повідомлення, а деталі можна записати в журнал сервера.
JSON у відповіді потрібно створювати через JSON.stringify().
JSON із запиту потрібно читати через JSON.parse() і обробляти можливі помилки.
Для JSON-відповідей слід встановлювати Content-Type: application/json.
HTTP-статус має описувати результат операції.
Для створення ресурсу використовується 201 Created.
Некоректні дані потрібно відхиляти зі статусом 400.
Відсутні маршрути або ресурси мають повертати 404.
Помилки API варто повертати в стабільному форматі.
Дані від клієнта потрібно перевіряти навіть після успішного парсингу JSON.