Files
ui/_docs/02_document/modules/src__api__client.md
T
Oleksandr Bezdieniezhnykh 510df68bcf [AZ-447] autodev Steps 1-4 baseline: docs, tests, refactor specs
Captures the full output of autodev existing-code Phase A through
Step 4 (Code Testability Revision) for the Azaion UI workspace:

- Step 1 Document: _docs/02_document/ (FINAL_report, architecture,
  glossary, components/, modules/, diagrams/, system-flows,
  module-layout) plus _docs/00_problem/ + _docs/01_solution/ +
  _docs/legacy/ + _docs/how_to_test + README.
- Step 2 Architecture Baseline: architecture_compliance_baseline.md.
- Step 3 Test Spec: _docs/02_document/tests/ (environment,
  test-data, blackbox/performance/resilience/security/
  resource-limit tests, traceability-matrix), enum_spec_snapshot,
  expected_results/results_report.md (98 rows), plus the
  run-tests.sh + run-performance-tests.sh runners.
- Step 4 Code Testability Revision: 01-testability-refactoring/
  run dir (list-of-changes C01-C07, deferred_to_refactor,
  analysis/research_findings + refactoring_roadmap) and the 7
  child task specs AZ-448..AZ-454 under _docs/02_tasks/todo/
  plus _dependencies_table.md.
- _docs/_autodev_state.md pins the cursor at Step 4 / refactor
  Phase 4 entry so /autodev resumes cleanly.

Epic AZ-447 (UI testability gates) tracks the 7 child tasks that
will land in subsequent commits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 00:38:49 +03:00

5.4 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 /api/admin/auth/refresh with credentials: 'include'. On 200, expects { token: string } and stores it. Returns boolean.

Dependencies

  • Internal: none.
  • 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 string literals at every call site (/api/admin/..., /api/flights?..., etc.). There is no base-URL constant. The vite.config.ts dev proxy and nginx.conf production rules forward /api/* to per-service backends.

A VITE_API_BASE_URL env-var fix is the canonical Step 4 testability candidate (workspace README.md calls this out).

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.