Files
ui/_docs/02_document/modules/src__api__client.md
T
Oleksandr Bezdieniezhnykh 17d5bb45e7
ci/woodpecker/push/build-arm Pipeline was successful
[AZ-485] [AZ-486] Cycle 1 docs refresh (Step 13)
Phase B cycle 1 was a structural refactor only: F4 (barrel imports +
STC-ARCH-01) and F7 (endpoint builders + STC-ARCH-02). This commit
brings docs in line with source after the cycle, no code changes.

Module docs (12 consumers): swap every /api/<service>/... literal in
code snippets and integration tables for the matching endpoints.*
builder; note the barrel import migration in Dependencies.

New module doc: src__api__endpoints.md (public surface, F4 barrel
re-export note, STC-ARCH-02 enforcement, contract-test reference).

Architecture compliance baseline: mark F4 + F7 CLOSED with commit
hashes (23746ec, 8a461a2).

01_api-transport component description: add endpoints.ts + barrel to
Internal Interfaces, close the F7 caveat, extend Module Inventory.

ripple_log_cycle1.md: Task Step 0.5 reverse-dep analysis records the
import-graph closure (no extra docs needed beyond the direct set).

Carry-over reports landed alongside the docs:
- test_run_report_phase_b_cycle1.md (Step 11 outcome)
- implementation_report_refactor_phase_b_cycle1.md (cycle summary)

State file: trimmed to the autodev <30-line target; Steps 14 + 15
recorded as SKIPPED with rationale (no security or perf surface
changed in this cycle); pointer moved to Step 16 (Deploy).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 00:01:04 +03:00

6.0 KiB

Module: src/api/client.ts

Source: src/api/client.ts (65 lines) Topo batch: B2 (leaf — no internal imports)

Purpose

Minimal fetch wrapper that injects the JWT bearer token, normalises HTTP errors into Error throws, and transparently retries a single time after a 401 by attempting a refresh. Acts as the single HTTP entry point for every page; there is no per-service typed client.

Public interface

Token plumbing:

export function setToken(token: string | null): void
export function getToken(): string | null

HTTP API:

export const api = {
  get:    <T>(url) => Promise<T>
  post:   <T>(url, body?) => Promise<T>
  put:    <T>(url, body?) => Promise<T>
  patch:  <T>(url, body?) => Promise<T>
  delete: <T>(url) => Promise<T>
  upload: <T>(url, formData: FormData) => Promise<T>
}

Internal logic

  • Module-level mutable variable let accessToken: string | null holds the current bearer token.
  • request<T>(url, options):
    1. Build a Headers from options.headers, inject Authorization: Bearer <token> if present.
    2. If options.body is a string, set Content-Type: application/json. (Crucial: upload() passes a FormData body, which is not a string, so Content-Type is left to the browser to set with the multipart boundary.)
    3. fetch(url, ...).
    4. On 401 and a present token: call refreshToken(). On success, set the new bearer and retry the same request once. On failure, clear the token and window.location.href = '/login', then throw "Session expired".
    5. Hand off to handleResponse<T>.
  • handleResponse<T>(res):
    • 204undefined as T.
    • !res.ok → throw new Error(\${status}: ${text || statusText}`). Body text is read defensively (.catch(() => '')`).
    • Otherwise → res.json() (no schema validation — caller types the response).
  • refreshToken()POST endpoints.admin.authRefresh() (i.e. /api/admin/auth/refresh) with credentials: 'include'. On 200, expects { token: string } and stores it. Returns boolean. (Path produced by the endpoints builder; closes F7.)

Dependencies

  • Internal: ./endpointsendpoints.admin.authRefresh() used by the internal refreshToken() helper (since AZ-486 / F7).
  • External: fetch, Headers, FormData, Response (browser globals). No npm runtime dependency.

Consumers (intra-repo)

The module-level api object is imported by:

  • src/auth/AuthContext.tsx (login / logout / initial refresh)
  • src/components/FlightContext.tsx (flights list, user settings get/put)
  • src/components/DetectionClasses.tsx (admin classes load)
  • src/features/admin/AdminPage.tsx
  • src/features/settings/SettingsPage.tsx
  • src/features/dataset/DatasetPage.tsx
  • src/features/flights/FlightsPage.tsx
  • src/features/annotations/{AnnotationsPage,AnnotationsSidebar,MediaList}.tsx

setToken is imported by AuthContext (login / refresh / logout). getToken is imported by src/api/sse.ts (to append the token to SSE URLs).

Data models

None defined here. The generic T parameter is supplied by call sites.

Configuration

URLs are produced by typed builders in src/api/endpoints.ts (see src__api__endpoints.md) — the F7 finding from the architecture baseline is now CLOSED. Every consumer (this module included) imports endpoints and calls endpoints.<service>.<method>(...); the STC-ARCH-02 static gate forbids re-introducing literal /api/<service>/... strings under src/.

There is no base-URL constant: the path strings are still relative. The vite.config.ts dev proxy and nginx.conf production rules forward /api/* to per-service backends. getApiBase() (exported from this module) supplies the host prefix at runtime where the consumer needs an absolute URL (e.g. the manual fetch(getApiBase() + endpoints.admin.authRefresh(), ...) call inside refreshToken()).

External integrations

Every backend the SPA talks to flows through this module. See nginx.conf for the routing table:

Path prefix Backend service
/api/admin/* admin/ (.NET)
/api/annotations/* annotations/ (.NET)
/api/flights/* flights/ (.NET)
/api/resource/* satellite-provider/
/api/detect/* detections/ (Cython)
/api/loader/* loader/ (Cython)
/api/gps-denied-desktop/* gps-denied-desktop/
/api/gps-denied-onboard/* gps-denied-onboard/
/api/autopilot/* autopilot/

Security

  • Token storage: in-memory only (accessToken: string | null at module scope). Survives in-tab navigations but not full reloads. The refresh path (POST /api/admin/auth/refresh with credentials: 'include') implies the refresh token rides in an HttpOnly cookie set by the admin/ service. The bearer access token is therefore short-lived and never persisted to localStorage. Acceptable XSS posture.
  • 401 handling: redirects to /login via window.location.href (full page reload) — clears any in-memory state including the bearer.
  • Race condition: two parallel requests that both 401 will both call refreshToken() independently — one will succeed, one may receive a stale token mid-flight. Track for B3/B4 audit; minor under the current usage but should be serialised in Step 8.
  • No CSRF token: relies on the bearer scheme only; credentials: 'include' is set only on /refresh, so other endpoints don't carry the cookie. Verify with admin/ service contract during Step 6 security_approach.md.

Tests

None.

Notes / open questions

  • The Authorization header is set BEFORE refreshToken() in the retry path, but refreshToken() mutates the module-level accessToken and the retry then headers.set('Authorization', \Bearer ${accessToken}`)` reads the NEW token. Correct, but worth a comment.
  • request is typed <T> and trusts callers; a runtime schema validation layer (Zod, valibot) would be the right Step 8 hardening but is too heavy for testability scope.
  • upload(url, formData) does NOT set Content-Type, allowing the browser to compute the multipart boundary. This is intentional and correct.