На пенсію api.ts: викликай майже будь-який API як локальну функцію
Оголоси ендпоінт один раз — типи, повтори й валідацію отримай задарма.
Майже в кожному проєкті є той самий файл, а часом і цілий модуль. Виглядає він по-різному — api.ts, apiClient.ts чи ціла директорія api/**. Але історія завжди однакова. Спершу це маленька обгортка навколо fetch, щоб не писати await res.json() всюди. За пів року той самий файл — уже неконтрольований монстр, якого ніхто в команді до кінця не розуміє, з коментарем // TODO: треба причесати, датованим двома роками тому. Він опікується авторизацією, обробкою помилок, повторами, валідацією — і всім іншим, що звʼязує API з вашим проєктом.
Я створював, рефакторив, виправляв і покращував цю обгортку стільки разів, що більше не можу робити одне й те саме в кожному новому проєкті. Саме тому я зробив open-source бібліотеку, яка бере цю рутину на себе, — StitchAPI.
Це не черговий фреймворк і не генератор коду. Ніяких серверів чи інфраструктури, жодних магічних конфіг-файлів із незрозумілим фінальним значенням. StitchAPI — це абстракція над fetch/axios/undici та іншим транспортом.
Ентропія
Розгляньмо, як маленька обгортка перетворюється на величезний модуль.
Крок 0. DRY — Do not repeat yourself.
async function api(url: string): Promise<any> {
const res = await fetch(url);
return res.json();
}
Кілька рядків коду — і нам уже не потрібно викликати .json() всюди.
Крок 1. Обробка помилок. Перша ж помилка від сервера — і ми знову сюди повертаємось.
async function api(url: string): Promise<any> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Крок 2. Базовий URL, заголовки, методи. У проєкт інтегрується все більше ендпоінтів, і ми не хочемо повторювати всюди спільні заголовки чи базовий URL та передавати метод параметром.
async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
}
Зверніть увагу на as Promise<T> наприкінці. У ньому закралася головна брехня цієї конструкції.
Крок 3. Path-параметри та query string.
const qs = new URLSearchParams();
if (filter) qs.set('filter', filter);
if (tags) tags.forEach((t) => qs.append('tag', t));
if (page != null) qs.set('page', String(page));
const res = await fetch(`${BASE_URL}/items?${qs.toString()}`, init);
Path-параметри вставляємо через template string: /users/${id}. А якщо id раптом undefined, path перетвориться на /users/undefined; а в body шлемо JSON.stringify(payload as any). TypeScript щасливий — зрештою, він лише статичний аналізатор, — а сервер відповідає 400, бо запит некоректний, або, гірше, виконує некоректну операцію. В аргументах типи брешуть так само, як і в результаті.
Крок 4. Авторизація. Потрібно оновлювати токен сесії.
async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
let res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: { Authorization: `Bearer ${getToken()}`, ...options.headers },
});
if (res.status === 401) {
await refreshToken(); // а якщо їх десять одночасно?
res = await fetch(`${BASE_URL}${path}`, {
/* ...те саме ще раз... */
});
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
}
На цьому етапі зʼявляється перше усвідомлення: // а якщо їх десять одночасно?. Лише два паралельних запити — і функція починає поводитися непередбачувано, і ви випадково влаштовуєте DDoS власному auth-сервісу. Потрібен мʼютекс — замок, що пропускає лише одне оновлення токена за раз, а решту викликів тримає в черзі. Це вже не маленька обгортка, це власний API-клієнт.
Крок 5. Повтори. API повертає 429 або 503. Додаємо backoff із джитером і обробкою Retry-After:
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url, init);
if (res.status === 429 || res.status >= 500) {
const retryAfter = Number(res.headers.get('Retry-After')) * 1000;
await sleep(retryAfter || 2 ** attempt * 200 + Math.random() * 100);
continue;
}
// ...а тепер та сама логіка оновлення токена, але всередині циклу...
return res.json() as Promise<T>;
}
І раптом виявляється, що повтори треба подружити з оновленням токена, оновлення — з таймаутами (AbortController, який ми ще навіть не додали), а повтори — з POST, який міг частково застосуватися. Кожна фіча конфліктує з іншою.
Крок 6. Логи. Рано чи пізно виникає потреба продебажити API-клієнт. Додаємо console.log(url, res.status). Це доростає до «тимчасового» інтерсептора, що логує кожен запит і містить уже не тільки ендпоінт та статус, а й payload. І одного дня ми бачимо credentials у логах. Це вже потенційний credentials-leak.
Крок 7. Те, що потрібно завжди, але майже ніколи не реалізоване. До цього моменту наш модуль уже доволі складний і великий. А ми досі не маємо: проактивного тротлінгу, скасування запитів, кешу, спостережуваності. На це «немає часу» — і не буде.
А тепер найголовніше — повернімося до того as Promise<T>.
const user = await api<User>('/users/1');
Це не гарантія, це обіцянка. Є багато сценаріїв, коли ця обіцянка перетворюється на брехню. Сервер перейменував email на emailAddress, або почав обгортати всі відповіді в конверт { data, error }, і ще багато інших причин.
Отак і народжується цей «монстр». Модуль, який ніхто не проєктував, ніхто не володіє і ніхто не наважується чіпати; де кожна фіча конфліктує з рештою, а типи скоріше побажання, ніж гарантія.
Це не чийсь поганий код — це нормальна ентропія. Я проходив цей шлях більше разів, ніж готовий зізнатися. І щоразу шлях різний, але результат завжди один і той самий.
Ретроспектива
Якщо передивитися, як ріс наш модуль, стає очевидним: кожен крок — це не баг, а вимога, яку ми так і не реалізували як слід. Не тому, що не вміємо, а тому, що реалізовувати їх на кожному проєкті немає часу. А перенести рішення з попереднього проєкту не можна — бо NDA, або воно не працюватиме з цим проєктом.
Ось чого мені постійно бракувало в цих рішеннях:
- Типи, які реально перевіряють — на обох сторонах. Не
as Promise<T>, а повноцінна валідація в рантаймі. Щоб перейменований, відсутній чи некоректний параметр був гучною помилкою одразу у виклику API, а не десь глибоко в застосунку, де UI некоректно відображає інформацію — або, гірше, не передав уже наявні поля назад разом з оновленими, що привело до data-loss. - Декларативна поведінка. Повтори, тротлінг, таймаути, скасування запитів — усе це має вмикатися й налаштовуватися одним параметром, а не вимагати реалізації та тестування цих алгоритмів знову і знову.
- Життєвий цикл авторизації. Керування в одному місці: оновлення токена, конкурентність, мʼютекс, зчитування стану авторизації. Повністю приховане від того, хто викликає.
- Спостережуваність. Я хочу бачити, як насправді виконувався запит: скільки тривав, скільки разів повторювався, чи дрифтувала відповідь — не завалюючи код десятками
console.log. А за потреби — підключити свій sink. - Нуль інфраструктури. Бібліотека, яку я просто імпортую, а не платформа, яку треба інтегрувати чи обслуговувати. Без сервера, без залежностей.
- Поступове впровадження. Один ендпоінт за раз, паралельно з наявним рішенням. Лінійна складність. Жодного «перепишемо весь API-клієнт за спринт» — такого спринту ніколи не буде.
- Жодного boilerplate. Оголошення ендпоінта і є клієнт. Жодного згенерованого SDK, конфіг-файлів чи інших налаштувань.
Та сама декларація працює як із коду, так і з CLI чи як інструмент для AI-агента; працює не лише з REST, а й з GraphQL, SSE чи навіть LLM. Але це вже тема іншої статті.
Тож список вище — не повний і не остаточний. Це радше зерно, з якого проросла бібліотека. Далі подивимось на неї в дії.
Будуємо API-клієнт зі StitchAPI
Згадаймо наш фінальний модуль із попереднього розділу: великий і складний, де обробляються повтори, тротлінг і життєвий цикл авторизації, де типи брешуть, а в логи потрапляє чутлива інформація. Тепер пройдемо той самий шлях зі StitchAPI — і подивимось, як код перетворюється на декларацію.
Крок 0. Найменший stitch. Один ендпоінт — один рядок:
import { stitch } from 'stitchapi';
const getUser = stitch('https://api.example.com/users/{id}');
const user = await getUser({ params: { id: 1 } }); // GET, розпарсений JSON
{id} — це URI-шаблон (RFC 6570); params, query, headers, body передаються аргументами. Поки що це наш крок 0 — тільки без ручного .json(), але вже з типізованими параметрами.
Крок 1. Типи, які не брешуть. Згадайте as Promise<T> — обіцянку компілятору, якої ніхто не дотримає. Замінюємо її на схему, яку перевіряють у рантаймі:
import { stitch } from 'stitchapi';
import { z } from 'zod';
const User = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const getUser = stitch({
path: 'https://api.example.com/users/{id}',
output: User,
});
const user = await getUser({ params: { id: 1 } });
// user: { id: number; name: string; email: string } — тип виведено зі схеми, не приведено
Різниця не косметична. Тепер тип user — це гарантія: помилка в цьому виклику не прослизне непоміченою. А ще в схемі можна оголосити дефолтні значення чи перетворення типів — stitch поверне нормалізований результат.
Але помилка може виникнути і на стороні, яка викликає. Аргументи функції теж треба валідувати:
import { stitch } from 'stitchapi';
import { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });
const NewUser = z.object({ name: z.string() });
const createUser = stitch({
method: 'POST',
path: 'https://api.example.com/users',
input: {
query: z.object({ notify: z.boolean().optional() }),
body: NewUser, // перевіряється ДО того, як запит піде в мережу
},
output: User,
});
await createUser({ query: { notify: true }, body: { name: 'Ada' } }); // аргумент типізовано зі схем input
Тепер некоректний body чи забутий параметр не прослизнуть непоміченими у виклик.
Крок 2. Дрифт: коли API тихо змінює форму. Валідація з Кроку 1 ловить критичні зміни: зникло обовʼязкове поле чи змінився тип — виклик одразу кидає помилку в API-клієнті, а не пускає биту форму далі в застосунок. Але не кожна зміна форми критична. API додав нове поле або почав коерсити тип, який ваша схема мовчки поглинає, — валідація проходить, і ви б ніколи про це не дізналися. as Promise<T> не бачив жодної з таких змін.
Обгортаємо схему в drift() — і stitch починає повідомляти щоразу, коли форма відповіді змінюється, навіть коли валідація мовчить:
import { drift, stitch } from 'stitchapi';
import { z } from 'zod';
const User = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const getUser = stitch({
path: 'https://api.example.com/users/{id}',
output: drift(User), // валідує, і додатково сигналить, коли форма відповіді попливла
});
const user = await getUser({ params: { id: 1 } });
// нове поле від API → подія `undeclared` (info); зникле обовʼязкове → throw ще на валідації
Це дві окремі перевірки, а не одне налаштування суворості. Валідація — жорсткий контракт: відсутнє обовʼязкове поле кидає помилку. Дрифт — мʼякий, не фатальний сигнал: undeclared (поле, якого немає у вашій схемі), coerced ("42" → 42), defaulted (спрацював .default()). Знані, але не споживані поля глушимо через ignore, а рівні перевизначаємо через severity:
import { drift, stitch } from 'stitchapi';
import { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });
const getUser = stitch({
path: 'https://api.example.com/users/{id}',
output: drift(User, {
ignore: ['meta'], // знане, але не споживане поле — не сигналити
severity: { coerced: 'info' }, // перевизначити рівень, не завалюючи виклик
}),
});
Оголошений optional, nullable чи порожній масив задовольняють схему, тож дрифт на них не спрацьовує: варіативність — це не дрифт. Ви оголошуєте, що може змінюватися, і бачите лише справжню зміну.
Крок 3. Стійкість. Памʼятаєте крок із циклом for, backoff, джитером і ручним читанням Retry-After? Ось він — але як налаштування:
import { stitch } from 'stitchapi';
import { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });
const listUsers = stitch({
path: 'https://api.example.com/users',
output: User.array(),
retry: 3,
throttle: '1/s',
timeout: '30s',
});
Крок 4. Авторизація за лаштунками — і кінець мʼютексу на оновлення токена. Памʼятаєте // а якщо їх десять одночасно? і мʼютекс на оновлення токена? Його більше немає — бо життєвим циклом токена володіє стратегія, а не ви:
import { bearer, drift, env, stitch } from 'stitchapi';
import { z } from 'zod';
const User = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const getUser = stitch({
path: 'https://api.example.com/users/{id}',
output: drift(User),
auth: bearer(env('API_TOKEN')), // секрет резолвиться на момент виклику; декларацію можна комітити
});
Уся та конкурентність з оновленням токена, через яку ми мало не поклали власний auth зливою повторних запитів, живе всередині стратегії. А той, хто викликає, отримує дані — секрет лишається прихованим від нього.
Крок 5. Спільна основа: seam. У реальності ендпоінтів тридцять, і всі ділять базу, авторизацію й ту саму політику повторів і тротлінгу. Це seam — оголосив спільне раз, кожен stitch його успадковує і ділить один рантайм (бакет тротлінгу, store, sink трейсів):
import { bearer, drift, env, seam } from 'stitchapi';
import { z } from 'zod';
const User = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const api = seam({
baseUrl: 'https://api.example.com',
auth: bearer(env('API_TOKEN')),
retry: { attempts: 3, on: [429, 503] },
throttle: { rate: '10/s', pool: 'host' },
timeout: { total: '30s' },
});
const getUser = api.stitch({ path: '/users/{id}', output: drift(User) });
const listUsers = api.stitch({
path: '/users',
output: User.array(),
pick: 'data',
});
const createUser = api.stitch({
method: 'POST',
path: '/users',
output: User,
idempotency: true,
});
(pick: 'data' дістає корисне навантаження з обгортки { data: … }, яку люблять віддавати REST-API.) Ось і весь наш монстр-модуль: один seam зі спільною політикою плюс рядок-два на ендпоінт. Без власного циклу повторів, без мʼютексу, без as T.
Крок 6. Спостережуваність. Памʼятаєте console.log, що дорослішає до інтерсептора, який логує токени? Тут логів у коді немає взагалі — спостережуваність вимкнена за замовчуванням і вмикається параметром: на окремому stitch або на всьому seam.
import { drift, seam } from 'stitchapi';
import { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });
const api = seam({ baseUrl: 'https://api.example.com' });
const getUser = api.stitch({
path: '/users/{id}',
output: drift(User),
trace: 'console', // або fileSink(path), або власний TraceSink
});
Вісім кроків монстра, де кожен дописував код, тут стали кількома полями в декларації. Це і є та різниця, заради якої я зробив StitchAPI.
Чим StitchAPI не є
Зрозуміти, чим інструмент не є, — хороший спосіб побачити його місце в стеку і чому рішення, які виглядають конкурентами, насправді стоять на інших рівнях стека: і StitchAPI їх доповнює, а не змагається з ними.
- Не заміна
fetchчиaxios. Це найперше непорозуміння.fetchіaxios— це транспорт підstitch.stitchсидить над транспортом: бере на себе типи, стійкість, авторизацію й дрифт, а сам запит віддає транспорту. Вашaxiosз інтерсепторами спокійно лишається движком —stitchпросто перестає змушувати вас реалізовувати поверх нього все інше вручну. - Не заміна react-query / TanStack Query. react-query керує серверним станом у React: кеш, дедуплікація, фонове оновлення, інвалідація, stale-while-revalidate. Але йому байдуже, що саме за запит — ви передаєте йому
queryFn, і зазвичай це... та сама обгортка навколоfetch, наш монстр-api.ts.stitch— ідеальнийqueryFn: react-query відповідає на питання коли запитати й що кешувати,stitch— на питання, що це взагалі за виклик (типи, валідація, стійкість, авторизація). Різні задачі, не суперники. І так, StitchAPI має інтеграцію з React зі схожим функціоналом. - Не генератор коду й не spec-first. Якщо у вендора є якісний OpenAPI-спек, який він реально підтримує, — генератор (Orval, Kubb, openapi-generator) чудово робить свою справу, і StitchAPI його не витісняє. Але codegen потребує повної актуальної специфікації наперед, а більшість реальних API — внутрішні й недокументовані.
- Не воркфлоу-движок і не iPaaS. Windmill, n8n, Temporal — це сервери, які ви розгортаєте заради оркестрації: черги, кроки, повтори між кроками, візуальний білдер. StitchAPI — бібліотека: жодного сервера, композиція — це звичайний TypeScript. Вони на різних рівнях стека:
stitchцілком може бути кроком усередині Windmill-флоу. - Не типізований клієнт із ручним контрактом. Zodios і ts-rest дають типобезпеку, але вимагають, щоб ви самі написали контракт, — і лишають повтори, життєвий цикл авторизації й виявлення дрифту на вас. StitchAPI вшиває це в сам примітив.
Жодне з цих рішень StitchAPI не намагається витіснити. Він закриває щілину, яку вони лишають: атомарний (один ендпоінт за раз), бібліотечний (нічого розгортати), зі стійкістю, авторизацією й дрифт-детекцією, вбудованими в сам примітив, а не дописаними збоку.
Підсумок
Монстр-api.ts — це не чийсь поганий код. Це нормальна ентропія. Поки API-клієнт лишається тим, що ви дописуєте руками в кожному проєкті, ви писатимете його знову і знову — і щоразу ті самі проблеми: типи, що брешуть; повтори, що конфліктують з оновленням токена; токени в логах.
StitchAPI — це спосіб перетворити ту саму роботу з коду на декларацію: типи, що реально перевіряють обидві сторони; стійкість одним параметром; авторизація як внутрішній механізм; спостережуваність без рядка в коді. А в перспективі та сама декларація працює як
Спробувати можна з одного ендпоінта, поряд із наявним кодом — або одразу в живому playground. Документація й гайди — на stitchapi.dev; код — на GitHub; пакет — на npm.
Disclaimer: я автор і мейнтейнер StitchAPI. Бібліотека — Apache-2.0, нуль залежностей; буду радий зворотному звʼязку, особливо критичному.
1 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів— rep for ai slop