Пошук уроків, статей та іншого контенту
Створите функцію-обгортку над fetch для єдиної конфігурації, авторизації, перевірки помилок і типових налаштувань.
fetchБез додаткової абстракції кожен HTTP-запит у застосунку повторює однакову логіку:
формування URL;
додавання базових заголовків;
серіалізація JSON;
передавання токена авторизації;
перевірка response.ok;
обробка порожніх відповідей;
встановлення тайм-ауту;
перетворення помилок на зрозумілий формат.
fetch відхиляє Promise лише у випадку мережевої помилки або скасування запиту. Відповіді з HTTP-статусами 400, 401, 404 чи 500 самі по собі не вважаються помилками для fetch.
Тому зручно створити одну функцію-клієнт, яка централізовано виконує цю роботу.
Зручна обгортка над fetch має відповідати за:
Побудову URL відносно baseUrl.
Додавання query-параметрів.
Об’єднання стандартних і локальних заголовків.
Додавання токена авторизації.
Серіалізацію об’єктів у JSON.
Встановлення тайм-ауту.
Парсинг відповіді.
Генерування типізованої помилки для неуспішних HTTP-відповідей.
Передавання додаткових опцій до fetch.
Сам клієнт не повинен знати, як саме компоненти використовують дані. Його завдання — забезпечити єдину поведінку всіх запитів.
Нижче наведено повну реалізацію клієнта без сторонніх бібліотек. Приклад працює у браузері та в Node.js 18+, де fetch є глобально доступним.
class ApiError extends Error {
constructor(message, {
status,
statusText,
url,
data,
headers,
}) {
super(message);
this.name = "ApiError";
this.status = status;
this.statusText = statusText;
this.url = url;
this.data = data;
this.headers = headers;
}
}
class ApiTimeoutError extends Error {
constructor(url, timeoutMs) {
super(`Запит перевищив тайм-аут ${timeoutMs} мс: ${url}`);
this.name = "ApiTimeoutError";
this.url = url;
this.timeoutMs = timeoutMs;
}
}
function createApiClient({
baseUrl,
getToken,
defaultHeaders = {},
timeoutMs = 10_000,
} = {}) {
if (!baseUrl) {
throw new Error("Параметр baseUrl є обов'язковим");
}
const normalizedBaseUrl = baseUrl.endsWith("/")
? baseUrl
: `${baseUrl}/`;
function buildUrl(path, query) {
const normalizedPath = String(path).replace(/^\/+/, "");
const url = new URL(normalizedPath, normalizedBaseUrl);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
url.searchParams.append(key, String(item));
}
} else {
url.searchParams.set(key, String(value));
}
}
}
return url;
}
async function parseResponse(response) {
if (response.status === 204) {
return undefined;
}
const text = await response.text();
if (!text) {
return undefined;
}
const contentType = response.headers.get("content-type") ?? "";
if (
contentType.includes("application/json") ||
contentType.includes("+json")
) {
try {
return JSON.parse(text);
} catch {
// Якщо сервер заявив JSON, але повернув некоректний текст,
// зберігаємо оригінальне тіло відповіді.
return text;
}
}
return text;
}
async function request(
path,
{
method = "GET",
query,
headers,
body,
signal,
...fetchOptions
} = {},
) {
const url = buildUrl(path, query);
const requestHeaders = new Headers(defaultHeaders);
if (headers) {
const customHeaders = new Headers(headers);
for (const [key, value] of customHeaders.entries()) {
requestHeaders.set(key, value);
}
}
if (getToken && !requestHeaders.has("Authorization")) {
const token = await getToken();
if (token) {
requestHeaders.set("Authorization", `Bearer ${token}`);
}
}
let requestBody = body;
const isFormData =
typeof FormData !== "undefined" && body instanceof FormData;
const isBlob =
typeof Blob !== "undefined" && body instanceof Blob;
const isUrlSearchParams =
typeof URLSearchParams !== "undefined" &&
body instanceof URLSearchParams;
const isJsonObject =
body !== null &&
typeof body === "object" &&
!isFormData &&
!isBlob &&
!isUrlSearchParams &&
!(body instanceof ArrayBuffer);
if (isJsonObject) {
requestBody = JSON.stringify(body);
if (!requestHeaders.has("Content-Type")) {
requestHeaders.set("Content-Type", "application/json");
}
}
const controller = new AbortController();
let timeoutId;
const abortFromCaller = () => {
controller.abort(signal.reason);
};
if (signal) {
if (signal.aborted) {
controller.abort(signal.reason);
} else {
signal.addEventListener("abort", abortFromCaller, {
once: true,
});
}
}
if (timeoutMs > 0) {
timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
}
try {
const response = await fetch(url, {
...fetchOptions,
method,
headers: requestHeaders,
body: requestBody,
signal: controller.signal,
});
const data = await parseResponse(response);
if (!response.ok) {
throw new ApiError(
`HTTP-помилка ${response.status}: ${response.statusText}`,
{
status: response.status,
statusText: response.statusText,
url: response.url,
data,
headers: response.headers,
},
);
}
return data;
} catch (error) {
if (controller.signal.aborted && !signal?.aborted) {
throw new ApiTimeoutError(url.toString(), timeoutMs);
}
throw error;
} finally {
clearTimeout(timeoutId);
if (signal) {
signal.removeEventListener("abort", abortFromCaller);
}
}
}
return {
request,
get(path, options = {}) {
return request(path, {
...options,
method: "GET",
});
},
post(path, body, options = {}) {
return request(path, {
...options,
method: "POST",
body,
});
},
put(path, body, options = {}) {
return request(path, {
...options,
method: "PUT",
body,
});
},
patch(path, body, options = {}) {
return request(path, {
...options,
method: "PATCH",
body,
});
},
delete(path, options = {}) {
return request(path, {
...options,
method: "DELETE",
});
},
};
}
// Приклад використання клієнта.
// Потрібен Node.js 18+ або середовище браузера.
const api = createApiClient({
baseUrl: "https://jsonplaceholder.typicode.com",
defaultHeaders: {
Accept: "application/json",
},
timeoutMs: 5_000,
getToken: async () => {
// У реальному застосунку токен можна отримувати зі сховища
// або оновлювати через окремий сервіс авторизації.
return undefined;
},
});
try {
const posts = await api.get("/posts", {
query: {
userId: 1,
},
});
console.log("Отримані записи:", posts);
const createdPost = await api.post("/posts", {
title: "Новий запис",
body: "Текст запису",
userId: 1,
});
console.log("Створений запис:", createdPost);
} catch (error) {
if (error instanceof ApiError) {
console.error("Помилка API:", {
status: error.status,
url: error.url,
data: error.data,
});
} else if (error instanceof ApiTimeoutError) {
console.error("Запит надто довго виконується:", error.url);
} else {
console.error("Непередбачена помилка:", error);
}
}Клієнт отримує базову адресу один раз:
const api = createApiClient({
baseUrl: "https://api.example.com/v1",
});Після цього окремі запити використовують лише шлях:
api.get("/users");
api.get("/users/42");Параметри передаються через поле query:
api.get("/users", {
query: {
page: 2,
limit: 20,
role: "admin",
},
});У результаті буде сформовано URL:
https://api.example.com/v1/users?page=2&limit=20&role=adminЗначення undefined і null ігноруються. Масиви додаються кілька разів:
api.get("/reports", {
query: {
status: ["new", "processing"],
},
});Результат:
/reports?status=new&status=processingДля побудови URL використовується URL, а не ручна конкатенація рядків. Це автоматично виконує правильне кодування спеціальних символів.
Заголовки можуть бути задані на трьох рівнях:
Загальні для всього клієнта.
Для конкретного запиту.
Автоматично клієнтом, наприклад Authorization або Content-Type.
const api = createApiClient({
baseUrl: "https://api.example.com",
defaultHeaders: {
Accept: "application/json",
"X-Client-Version": "2.4.0",
},
});
const result = await api.get("/profile", {
headers: {
"X-Request-ID": crypto.randomUUID(),
},
});Локальні заголовки мають вищий пріоритет за стандартні. Для цього використовується об’єкт Headers, який коректно обробляє регістр назв заголовків.
Токен не варто жорстко зашивати всередині клієнта. Замість цього клієнт отримує функцію getToken.
const api = createApiClient({
baseUrl: "https://api.example.com",
getToken: async () => {
return localStorage.getItem("access_token");
},
});Тепер кожен запит автоматично отримає заголовок:
Authorization: Bearer <token>Асинхронний getToken корисний, коли токен:
зберігається в IndexedDB;
надходить від окремого сховища;
може потребувати оновлення;
отримується з серверної сесії.
Передавання локального заголовка Authorization не буде перезаписане:
api.get("/public-data", {
headers: {
Authorization: "Basic some-value",
},
});Це може бути корисно для окремих endpoint-ів із нестандартною схемою авторизації.
Сам факт отримання статусу 401 ще не означає, що потрібно повторювати запит. Повторення має сенс лише після успішного оновлення токена.
Типовий алгоритм:
Виконати запит із поточним access token.
Отримати 401.
Один раз оновити access token через refresh token.
Повторити початковий запит.
Якщо повторно отримано 401, завершити сесію користувача.
Важливо не реалізовувати безумовні повтори всередині загальної обгортки. Інакше можна отримати нескінченний цикл або одночасно запустити багато операцій оновлення токена.
Для JavaScript-об’єктів клієнт автоматично виконує:
JSON.stringify(body);і додає:
Content-Type: application/jsonПриклад:
await api.post("/users", {
name: "Olena",
email: "olena@example.com",
});Мережевий запит отримає JSON-тіло:
{
"name": "Olena",
"email": "olena@example.com"
}Спеціальні типи не серіалізуються в JSON:
FormData;
Blob;
URLSearchParams;
ArrayBuffer.
Це необхідно для завантаження файлів:
const formData = new FormData();
formData.append("title", "Документ");
formData.append("file", file);
await api.post("/documents", formData);Для FormData не потрібно вручну встановлювати Content-Type. Браузер сам додасть multipart/form-data разом із boundary. Якщо встановити цей заголовок вручну, сервер може не змогти правильно розібрати тіло запиту.
Клієнт читає тіло відповіді лише один раз через response.text(). Це важливо, оскільки тіло response є потоком і повторне виконання response.json() після response.text() призведе до помилки.
Алгоритм парсингу:
204 No Content повертає undefined;
порожнє тіло повертає undefined;
JSON-відповідь перетворюється на JavaScript-значення;
інший тип відповіді повертається як рядок.
Тому можна працювати з відповіддю однаково:
const user = await api.get("/users/42");
console.log(user.id);
console.log(user.name);Для endpoint-а, який повертає текст:
const healthStatus = await api.get("/health");
console.log(healthStatus);Правильна перевірка має бути явною:
if (!response.ok) {
throw new ApiError(...);
}Властивість response.ok має значення true для статусів від 200 до 299.
Створення власного класу помилки дає змогу зберегти важливу інформацію:
HTTP-статус;
URL;
текст статусу;
тіло відповіді;
заголовки.
Наприклад, сервер може повернути:
{
"message": "Користувача не знайдено",
"code": "USER_NOT_FOUND"
}Код виклику може обробити це так:
try {
await api.get("/users/999");
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
console.log("Показати повідомлення про відсутнього користувача");
console.log(error.data.message);
} else {
throw error;
}
}Не слід покладатися лише на error.message. Для програмної логіки потрібно перевіряти клас помилки та її структуровані властивості.
За замовчуванням fetch не має автоматичного тайм-ауту. Якщо сервер не відповідає, Promise може залишатися активним дуже довго.
Клієнт створює AbortController і скасовує запит після завершення заданого інтервалу:
const api = createApiClient({
baseUrl: "https://api.example.com",
timeoutMs: 3_000,
});Тайм-аут і ручне скасування мають різні причини:
ApiTimeoutError означає, що минув заданий час;
AbortError або причина зовнішнього сигналу означає, що запит скасував викликач.
Ручне скасування:
const controller = new AbortController();
const request = api.get("/large-report", {
signal: controller.signal,
});
setTimeout(() => {
controller.abort();
}, 1_000);
try {
const report = await request;
console.log(report);
} catch (error) {
if (error.name === "AbortError") {
console.log("Запит скасовано користувачем");
}
}У браузерному застосунку це корисно під час:
переходу на іншу сторінку;
розмонтування компонента;
зміни пошукового запиту;
натискання кнопки «Скасувати».
fetchОбгортка не повинна приховувати всі можливості fetch. Невідомі клієнту опції передаються через ...fetchOptions.
Наприклад:
await api.get("/private-data", {
cache: "no-store",
credentials: "include",
});Це дозволяє використовувати:
credentials;
cache;
mode;
redirect;
інші стандартні опції fetch.
Водночас критичні параметри, такі як method, headers, body і signal, контролюються клієнтом. Це забезпечує єдину поведінку запитів.
Повторний запит не завжди безпечний.
Без додаткових умов зазвичай не слід автоматично повторювати:
POST, який може створити дубль;
PATCH зі складними побічними ефектами;
DELETE, якщо сервер не гарантує ідемпотентність;
запити після помилки валідації 400;
запити після 401, якщо токен не оновлено.
Повтори частіше доречні для:
тимчасових мережевих помилок;
408 Request Timeout;
429 Too Many Requests;
частини помилок 5xx.
Якщо сервер повертає Retry-After, цей заголовок потрібно враховувати. Кількість спроб має бути обмеженою, наприклад двома або трьома, а між ними варто використовувати backoff.
Особливу увагу потрібно приділяти ідемпотентності. Повторний GET зазвичай не змінює стан сервера, а повторний POST може створити два ресурси.
Клієнт зручно тестувати окремо від компонентів і бізнес-логіки. У тестах можна замінити глобальний fetch функцією-заглушкою та перевірити:
побудову URL;
query-параметри;
заголовок Authorization;
серіалізацію JSON;
обробку 204;
перетворення статусу 404 на ApiError;
скасування через AbortController;
тайм-аут.
Важливо тестувати не лише успішний сценарій. Помилки мережі, некоректний JSON і порожні відповіді часто є джерелом проблем у production.
Неправильно:
const response = await fetch("/api/users");
try {
return await response.json();
} catch {
console.log("HTTP-запит неуспішний");
}Статус 404 або 500 не спричиняє автоматичного catch. Потрібно перевіряти response.ok.
response.json() для відповіді 204Відповідь 204 No Content не має тіла. Спроба виконати response.json() може спричинити помилку парсингу.
Content-Type: application/json для FormDataЦе ламає multipart-запит, оскільки boundary має генерувати браузер.
Такий підхід призводить до дублювання та різної поведінки endpoint-ів. Токен краще додавати централізовано через getToken.
Помилка лише з текстом HTTP 400 часто недостатня. Сервер може повернути код правила валідації, назву поля або деталізоване повідомлення.
Мережевий запит може зависнути довше, ніж користувач готовий чекати. Клієнт повинен мати обмеження часу або отримувати його з конфігурації.
Автоматичний retry для операцій, які змінюють дані, може створити дублікати або повторні побічні ефекти.
baseUrl і шлях без визначених правилВідмінності між такими адресами можуть призвести до несподіваного URL:
https://api.example.com/v1
https://api.example.com/v1/Варто нормалізувати baseUrl і використовувати URL, а не ручне складання рядків.
У реальному проєкті поверх базової обгортки можна додати:
окремі модулі usersApi, ordersApi, reportsApi;
автоматичне оновлення access token;
retry із backoff;
логування request ID;
метрики тривалості запитів;
підтримку ETag і If-None-Match;
кешування GET-запитів;
валідацію відповіді через схеми;
централізоване перетворення помилок;
кореляційний ідентифікатор для трасування запитів.
Наприклад, доменний модуль може використовувати клієнт так:
function createUsersApi(api) {
return {
list({ page = 1, limit = 20 } = {}) {
return api.get("/users", {
query: { page, limit },
});
},
findById(id) {
return api.get(`/users/${encodeURIComponent(id)}`);
},
create(input) {
return api.post("/users", input);
},
update(id, input) {
return api.patch(`/users/${encodeURIComponent(id)}`, input);
},
remove(id) {
return api.delete(`/users/${encodeURIComponent(id)}`);
},
};
}
const usersApi = createUsersApi(api);
const users = await usersApi.list({
page: 1,
limit: 10,
});У такій структурі UI-код не знає деталей URL, заголовків і fetch. Він працює з предметними операціями, а мережеві правила залишаються в одному місці.
Обгортка над fetch повинна централізувати повторювану інфраструктурну логіку:
базову адресу;
query-параметри;
заголовки;
авторизацію;
JSON-серіалізацію;
парсинг відповідей;
перевірку HTTP-статусів;
тайм-аути;
скасування запитів;
структуровані помилки.
fetch є низькорівневим інструментом, тому надійний API-клієнт має явно визначати правила його використання. Водночас обгортка повинна залишатися прозорою: передавати стандартні опції fetch, не приховувати важливі помилки та не виконувати небезпечні автоматичні повтори без урахування методу і побічних ефектів.