На пенсію api.ts: викликай майже будь-який API як локальну функцію

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Оголоси ендпоінт один раз — типи, повтори й валідацію отримай задарма.

Майже в кожному проєкті є той самий файл, а часом і цілий модуль. Виглядає він по-різному — 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 — це спосіб перетворити ту саму роботу з коду на декларацію: типи, що реально перевіряють обидві сторони; стійкість одним параметром; авторизація як внутрішній механізм; спостережуваність без рядка в коді. А в перспективі та сама декларація працює як CLI-команда, HTTP-ендпоінт чи інструмент для AI-агента — і не лише з REST, а й з GraphQL, SSE та LLM. Але це вже тема для окремої статті.

Спробувати можна з одного ендпоінта, поряд із наявним кодом — або одразу в живому playground. Документація й гайди — на stitchapi.dev; код — на GitHub; пакет — на npm.

Disclaimer: я автор і мейнтейнер StitchAPI. Бібліотека — Apache-2.0, нуль залежностей; буду радий зворотному звʼязку, особливо критичному.

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному6
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі