---
layout: 'page'
uri: '/skills/gk-frontend-fetch'
position: 10
slug: 'skills-gk-frontend-fetch'
parent: 'skills-frontend'
navTitle: 'gk-frontend-fetch'
title: 'GK — Frontend data fetching'
description: 'FE data fetching — apiFetch vs authFetch, single-flight refresh + self-heal session, access token jen v paměti, discriminated-union ApiResponse, upload/download. Use when voláš z Vue backend API, řešíš proč ti request padá na 401, jak se obnovuje session, nebo jak vrátit data/chybu z fetch helperu.'
name: 'gk-frontend-fetch'
---

# GK — Frontend data fetching

Jak frontend (Vue SPA) volá backend API: jeden malý transport (`apiFetch`), jeho chráněná varianta s auto-refreshem (`authFetch`), a session, která se sama uzdraví po výpadku.

## What & when

- Sáhni sem, když: voláš z Vue komponenty/composable nějaký `/api/v1/...` endpoint, nevíš jestli `apiFetch` nebo `authFetch`, řešíš „proč mi request spadl na 401 a co se děje s tokenem", nebo přidáváš upload/download souboru.
- NEtýká se: tvar chybové odpovědi z backendu → na který HTTP status se mapuje (`/gk-errors`), ani jak backend login/refresh/logout a rotace tokenů funguje uvnitř (`/gk-auth`). Permission helpery (`hasPermission`…) sem patří jen okrajově — řídí UI, autoritativní je backend (`/gk-permissions`). Kam soubor patří a FE konvence řeší `/gk-frontend-ui`.

## For non-tech / juniors

Frontend a backend jsou dva oddělené programy; mluví spolu přes HTTP requesty (pošli data, dostaň odpověď). „Fetch helper" je naše obálka nad prohlížečovým `fetch`, aby se to volalo na jeden řádek a odpověď měla pořád stejný tvar.

Po přihlášení dostane frontend **access token** — krátkodobou propustku, kterou přikládá ke každému requestu. Token schválně **držíme jen v paměti** (JS proměnná), ne v `localStorage`: kdyby útočník propašoval do stránky cizí skript (XSS), z paměti běžící aplikace ho tak snadno nevytáhne. Cena: po tvrdém refreshi stránky je paměť prázdná — proto existuje „refresh", co propustku tiše obnoví z HttpOnly cookie (tu zase nevidí JS).

Access token brzo vyprší. Aplikace ho proto sama vyměňuje na pozadí 30 s před koncem. A když to volání jednou selže kvůli krátkému výpadku sítě/serveru, **session se neodhlásí** — počká a zkusí to znovu (self-heal). Odhlásí tě jen definitivní „tahle propustka už neplatí" (401) nebo když klikneš na Logout.

## How it works

Dvě vrstvy. **Fetch** (`assets/app-ui/Fetch/`) = surový transport bez retry. **Auth** (`assets/app-ui/Auth/`) = session, refresh a chráněná varianta.

**`apiFetch<TData, TError, TBody>(method, url, options)`** (`Fetch/apiFetch.ts`) — pošle JSON, vrátí `ApiResponse`. Přes `buildAuthHeaders` (`Fetch/buildHeaders.ts`) **přiloží `Authorization: Bearer`, kdykoli je v paměti token** — takže to není „bez auth", jen to **nemá refresh/retry**. Používej ho na public endpointy (`/health`) a interně ho volají i auth endpointy samotné (login/refresh/logout).

**`authFetch<TData, TError, TBody>(method, url, options)`** (`Auth/authFetch.ts`) — `apiFetch` + **jednorázový** retry na 401: zavolá `refresh()`, a když uspěje, **zopakuje request jednou**. Když refresh vrátí false, vrátí původní 401 (žádná smyčka). `/api/v1/auth/*` se schválně přeskakuje (login 401 = špatné heslo, refresh by se zacyklil, logout je one-shot). **Pro každý chráněný endpoint používej `authFetch`.**

**`TBody` je povinný, jakmile posíláš body** — default je `never` a `body?: NoInfer<TBody>` brání kompilátoru odvodit typ z argumentu, takže `{ body: x }` bez třetí generiky se nezkompiluje v žádném tvaru volání (i přes alias/namespace import, kam ESLint nevidí). Deklaruj ho **generovaným** request typem (`UserFormData`, `LoginRequest`, …) — payload je pak strukturálně kontrolovaný proti Go DTO, které handler dekóduje (FE polovina wire hranice; BE polovinu hlídá `gk boundary`); že sáhneš po generovaném a ne ad-hoc typu, je konvence viditelná v review. ESLint navíc vyžaduje explicitní generiky na každém volání a zakazuje inline `body` literály — payload teče z typované proměnné. GET/DELETE bez body zůstávají dvougenerické.

**`validate` je povinné, jakmile data PŘIJÍMÁŠ** (`TData ≠ null`) — předej **generovaný guard** (`validate: isAdminUser`, pro seznamy `arrayOf(isAdminUser)`); bez něj se volání nezkompiluje a párování guard↔generika hlídá kompilátor (`Guard<TData>`). 2xx tělo se za běhu ověří proti generovanému kontraktu: porušení = `{ general }` failure + **Sentry report** (BE prokazatelně posílá anotované DTO, takže neshoda = bug — typicky špatné spárování URL↔typ na call site). 204 endpointy (`TData = null`) guard nemají.

**Selhání vždy jednořádkově merguješ** — `data` na failure je `TErrors | { general: string }`: chybové tělo z API, když přišlo, jinak syntetizované `{ general: … }` (síť / rozbité tělo / porušený kontrakt — stejný klíč `general`, kterým BE posílá ne-field chyby). Každý `*Errors` typ má `general?: string`, takže `errors.value = result.data;` funguje bez zužování a network error se uživateli ukáže v general slotu formuláře.

**Access token** (`Fetch/accessToken.ts`) — jediná modulová proměnná `let accessToken`, `get/setAccessToken`. Jen v paměti, XSS-resistentní. Po hard-refreshi je prázdná → obnoví ji bootstrap.

**`ApiResponse<TData, TError>`** (`Fetch/types/ApiResponse.ts`) — discriminated union (rozlišená podle `success`):
```typescript
const r = await authFetch<AdminUser, UserFormErrors>('GET', `/api/v1/admin/users/${id}`, { validate: isAdminUser });
if (r.success === true)  { r.data; }   // ApiSuccess<TData>: { success:true,  status, data }
if (r.success === false) { r.data; }   // ApiError<TError>:   { success:false, status, data }
```
`parseResponse` (`Fetch/parseResponse.ts`) staví union podle `response.ok`. Všechna selhání bez použitelného chybového JSON objektu syntetizuje jako `{ general: … }` (helper `generalFailure`, sdílený i apiFetchCore/apiUpload): 2xx s nenaparsovatelným tělem (`Malformed response body`), 2xx porušující guard (`Invalid response shape` + Sentry, URL v reportu normalizované — UUID → `:id`, ať se jeden bug negrupuje na issue per entita), chybový status s prázdným/ne-objektovým tělem (`Error <status>`; holý JSON string od proxy se použije jako hláška) i síťová chyba (`status: 0`, nikdy výjimka). Chybové tělo se předá jako `TError` jen když je to skutečný JSON objekt (`isRecord`). 2xx s prázdným tělem bez guardu je success s `data: null`. Default `TError` je `ApiGeneralError` (`{ general: string }`) — jediný tvar, který selhání reálně produkují; oba fasády sdílí jeden kontrakt `TypedFetchFn` (`Fetch/types/TypedFetchFn.ts`), takže se nemůžou rozjet. `apiFetchCore` (volná implementace pod fasádami) je pro aplikační kód zakázaný import (ESLint `no-restricted-imports`) — obcházel by celou validate↔TData disciplínu.

**Single-flight refresh** žije v `refresh()` (`Auth/refresh.ts`), v `inFlight` guardu — **ne** v authFetch. Bootstrap, časovač 30 s před expirací, jeho retry i 401-retry z authFetch **sdílí jednu rotaci** cookie. Je to **bezpečnostní vlastnost**: paralelní rotace téže cookie backend (compare-and-swap nad `used_at`) vyhodnotí jako krádež tokenu a session natvrdo odhlásí.

**Self-heal** — `runRefresh()` / `onTransientFailure()` v `refresh.ts`:

| Výsledek `POST /api/v1/auth/refresh` | Reakce |
|---|---|
| 200 + validní tělo | nová session, `setAccessToken`, naplán další refresh |
| **401** (definitivní) | `clearSessionHint()` + `clearAuth()` → odhlášení |
| 200 malformed / 5xx / network error | **transient**: pokud `isAuthenticated && retries < 5` → jittered backoff (2 s base, ±50 %), session zůstane; jinak `clearAuth()` ale **hint zůstane** |

Asymetrie hintu JE ten self-heal: `clearAuth` (`Auth/state.ts`) schválně **nemaže** `gk_session` cookie (`Auth/sessionHint.ts`), takže další načtení stránky může refresh zkusit znovu. Hint se zruší jen při logoutu a 401.

**Bootstrap** (`assets/app.ts` → `bootstrap()`) — při hard-refreshi: pokud `hasSessionHint() === true`, zavolá `await refresh()` ještě před mountem routeru, takže se session tiše obnoví z cookie. `gk_session=1` je čitelná cookie vedle HttpOnly refresh cookie (JS HttpOnly nevidí) — ušetří zbytečný 401, když session zjevně není.

**Upload / download** (`Fetch/apiUpload.ts`, `Fetch/apiDownload.ts`) — `apiUpload<TData>(url, formData, { validate, onProgress? })` běží přes `XMLHttpRequest` (kvůli progress eventům; `validate` je povinné — response je v paritní smyčce), `apiDownload(url, fallbackFilename)` stáhne Blob a spustí browser dialog (filename z `Content-Disposition`, fallback parametr). Oba přikládají token přes `buildAuthHeaders`, ale jsou ve Fetch vrstvě — **bez 401 refreshe**.

## Recipe

### Recipe: zavolat chráněný endpoint z komponenty
1. Importuj: `import { authFetch } from '@/app-ui/Auth';` + generovaný guard k tomu, co endpoint **reálně** vrací: `import { isAdminUserListResponse } from '@/app/Admin/types/AdminUserListResponse';` (u endpointu vracejícího holé pole navíc `arrayOf` z `@/app-ui/Fetch/guards`).
2. Zavolej s typy i guardem: `const r = await authFetch<AdminUserListResponse>('GET', '/api/v1/admin/users?page=1', { validate: isAdminUserListResponse });` — bez `validate` se volání s `TData ≠ null` nezkompiluje.
   **Guard musí sedět na skutečný tvar odpovědi.** List endpointy jsou stránkované, takže vracejí `{ items, total }`, ne pole — `arrayOf(isAdminUser)` by se tu přeložil (TS tvar odpovědi nezná), ale za běhu by vrátil false → `{ general: 'Invalid response shape' }` + hlášení do Sentry. To je přesně to špatné spárování URL↔typ z Pitfalls. `arrayOf(guard)` patří jen na endpoint, který holé pole opravdu vrací.
3. Větvi přes `if (r.success === true)` / `=== false` (nikdy `if (!r…)` — viz CLAUDE.md FE pravidla).
4. Chybu napoj na formulářové pole: backend keyuje chyby podle pole → `errors.value = r.data;` (detail v `/gk-errors`).

### Recipe: public endpoint (bez nutnosti session)
1. `import { apiFetch } from '@/app-ui/Fetch';`
2. `const r = await apiFetch<{ status: string }, ApiGeneralError>('GET', '/health', { validate: isHealth });` — token se přiloží jen pokud existuje, ale na 401 se NEretrí. (`/health` je infra-only a schválně stojí mimo tsgen — typ i mini-guard `isHealth` si napiš vlastní přes primitiva z `@/app-ui/Fetch/guards`.)

### Recipe: upload s progressem
1. `import { apiUpload } from '@/app-ui/Fetch';`
2. `await apiUpload<Result>('/api/v1/files', formData, { validate: isResult, onProgress: (s) => { s.percent; s.loaded; s.total; } });` — `validate` je u uploadu povinné vždy (response je v paritní smyčce).

## Invariants & pitfalls

- **Access token jen v paměti.** Nikdy ho neukládej do `localStorage`/`sessionStorage`/cookie. Jediný úložný bod je `Fetch/accessToken.ts`.
- **Chráněný endpoint → `authFetch`, ne `apiFetch`.** `apiFetch` token sice přiloží, ale po expiraci nezavolá refresh → request prostě skončí chybou 401.
- **`apiUpload` / `apiDownload` neretrují na 401.** Jsou ve Fetch vrstvě. Pokud token mezitím vypršel, chráněný upload/download selže — případně si nejdřív vynuť `await refresh()`.
- **authFetch retry je jednorázový.** Refresh uspěje → jeden opakovaný request; refresh false → původní 401. Nezacyklí se. `/api/v1/auth/*` se neretrí vůbec.
- **Single-flight = bezpečnost, ne optimalizace.** Nikdy nerotuj refresh cookie paralelně (vlastní souběžné `refresh()` mimo `inFlight` guard) — backend to vyhodnotí jako krádež tokenu a odhlásí.
- **Hint mažou jen logout a 401.** Při transientním selhání (5xx/offline) `clearAuth` hint NECHÁVÁ, aby další load mohl self-healnout. Nemaž `gk_session` při dočasné chybě.
- **Cross-tab souběh je roadmap trade-off.** Jitter jen de-synchronizuje retry napříč taby; plná koordinace mezi taby zatím není hotová — neprezentuj ji jako shipped.
- **Žádná FE validace.** Tvary `TError` jen typují odpověď serveru; autoritativní validace je vždy na backendu (CLAUDE.md).

## Related

- Skills: `/gk-errors` (tvar `data` v `ApiError` ↔ HTTP status mapping na backendu), `/gk-auth` (backend dvou-tokenový login/refresh/logout, rotace + theft detection, kterou single-flight chrání), `/gk-permissions` (zdroj pravdy pro `hasPermission`), `/gk-frontend-ui` (struktura FE, kam komponenta/util patří)
- Kód: `assets/app-ui/Fetch/` (`apiFetch`, `parseResponse`, `buildHeaders`, `accessToken`, `apiUpload`, `apiDownload`, `types/ApiResponse`), `assets/app-ui/Auth/` (`authFetch`, `refresh`, `login`, `logout`, `state`, `sessionHint`, `useAuth`), `assets/app.ts` (`bootstrap`)

---

[← Frontend (Vue 3 SPA)](/skills/frontend.md) | [gk-frontend-forms →](/skills/gk-frontend-forms.md)