Spaces:
Sleeping
Sleeping
Download frontend/lib/api-client.ts from Aynkhn/Face-Expression-Analyzer: direct link, hf CLI and curl.
- Browser
- Download file 7.97 kB
-
https://huggingface.co/spaces/Aynkhn/Face-Expression-Analyzer/resolve/main/frontend/lib/api-client.ts
- Command line
-
hf download hf://spaces/Aynkhn/Face-Expression-Analyzer/frontend/lib/api-client.ts
-
curl -L -o api-client.ts https://huggingface.co/spaces/Aynkhn/Face-Expression-Analyzer/resolve/main/frontend/lib/api-client.ts
7.97 kB
| /** | |
| * The typed API client. | |
| * | |
| * Replaces eight ad-hoc `fetch` calls spread across the dashboard, the settings | |
| * modal and the logger. Two things are deliberate here: | |
| * | |
| * 1. Every call returns a `Result` rather than throwing. Callers cannot ignore the | |
| * failure case, which is what let a dead backend previously render as the | |
| * "no records found" empty state. | |
| * 2. A 401 is a distinct variant. Previously `if (res.ok)` treated an auth failure | |
| * and an empty database identically, so a wrong key looked like no data. | |
| */ | |
| import { getSessionId } from "@/lib/session"; | |
| import { getSettings } from "@/lib/storage"; | |
| import type { | |
| BulkCreateResult, | |
| DeleteResult, | |
| EmotionLogInput, | |
| EmotionLog, | |
| LogQueryParams, | |
| LogsPage, | |
| LogStats, | |
| } from "@/types/log"; | |
| import type { HealthResponse } from "@/types/connection"; | |
| export type ApiErrorKind = | |
| | "unauthorized" | |
| | "not_found" | |
| | "rate_limited" | |
| | "validation" | |
| | "server" | |
| | "network" | |
| | "timeout" | |
| | "aborted"; | |
| export interface ApiError { | |
| kind: ApiErrorKind; | |
| status: number | null; | |
| message: string; | |
| requestId?: string; | |
| } | |
| export type Result<T> = { ok: true; data: T } | { ok: false; error: ApiError }; | |
| const DEFAULT_TIMEOUT_MS = 15_000; | |
| /** | |
| * Resolve the API origin. | |
| * | |
| * Order matters. The build-time env var wins so the Docker image can be pointed at | |
| * a specific host; an empty value falls through to the current origin, which is | |
| * correct when the FastAPI process serves the exported bundle itself. The stored | |
| * override is last, for pointing a local dev frontend at a remote backend. | |
| * | |
| * Previously `process.env` was read nowhere in the app and the URL was hardcoded to | |
| * `http://localhost:8000`, which is why the container served a bundle that called | |
| * localhost while the server listened on 7860. | |
| */ | |
| export function resolveBaseUrl(override?: string): string { | |
| const stored = override ?? (typeof window !== "undefined" ? getSettings().backendUrl : ""); | |
| if (stored) return stored.replace(/\/$/, ""); | |
| const fromEnv = process.env.NEXT_PUBLIC_BACKEND_URL; | |
| if (fromEnv) return fromEnv.replace(/\/$/, ""); | |
| if (typeof window !== "undefined") return window.location.origin; | |
| return ""; | |
| } | |
| function errorKindFor(status: number): ApiErrorKind { | |
| if (status === 401 || status === 403) return "unauthorized"; | |
| if (status === 404) return "not_found"; | |
| if (status === 429) return "rate_limited"; | |
| if (status === 400 || status === 422) return "validation"; | |
| return "server"; | |
| } | |
| const MESSAGES: Record<ApiErrorKind, string> = { | |
| unauthorized: "The server rejected this request.", | |
| not_found: "Not found.", | |
| rate_limited: "Rate limit reached. Try again shortly.", | |
| validation: "The request was rejected as invalid.", | |
| server: "The server failed to handle the request.", | |
| network: "Could not reach the backend.", | |
| timeout: "The request timed out.", | |
| aborted: "The request was cancelled.", | |
| }; | |
| interface RequestOptions { | |
| method?: "GET" | "POST" | "DELETE"; | |
| body?: unknown; | |
| /** Admin routes need the private key; ingest routes take the public one. */ | |
| /** Only the write path carries a key; reads are scoped by session id. */ | |
| auth?: "ingest" | "none"; | |
| signal?: AbortSignal; | |
| timeoutMs?: number; | |
| baseUrl?: string; | |
| } | |
| async function request<T>(path: string, options: RequestOptions = {}): Promise<Result<T>> { | |
| const { | |
| method = "GET", | |
| body, | |
| auth = "none", | |
| signal, | |
| timeoutMs = DEFAULT_TIMEOUT_MS, | |
| baseUrl, | |
| } = options; | |
| const settings = getSettings(); | |
| const headers: Record<string, string> = {}; | |
| if (body !== undefined) headers["Content-Type"] = "application/json"; | |
| if (auth === "ingest") headers["X-API-Key"] = settings.ingestKey; | |
| // Sent on every request: it is what scopes reads, writes and deletes to this | |
| // browser's own records. | |
| const sessionId = getSessionId(); | |
| if (sessionId) headers["X-Session-Id"] = sessionId; | |
| const timeoutController = new AbortController(); | |
| const timer = setTimeout(() => timeoutController.abort(), timeoutMs); | |
| // Combine the caller's signal with the timeout so either can cancel. | |
| const signals = signal ? [signal, timeoutController.signal] : [timeoutController.signal]; | |
| const combined = | |
| typeof AbortSignal !== "undefined" && "any" in AbortSignal | |
| ? AbortSignal.any(signals) | |
| : timeoutController.signal; | |
| try { | |
| const response = await fetch(`${resolveBaseUrl(baseUrl)}${path}`, { | |
| method, | |
| headers, | |
| body: body === undefined ? undefined : JSON.stringify(body), | |
| signal: combined, | |
| }); | |
| if (!response.ok) { | |
| let message = MESSAGES[errorKindFor(response.status)]; | |
| let requestId: string | undefined; | |
| try { | |
| const payload = await response.json(); | |
| if (typeof payload?.message === "string" && response.status !== 500) { | |
| message = payload.message; | |
| } | |
| if (typeof payload?.request_id === "string") requestId = payload.request_id; | |
| } catch { | |
| // A non-JSON error body is fine; the status-derived message stands. | |
| } | |
| return { | |
| ok: false, | |
| error: { | |
| kind: errorKindFor(response.status), | |
| status: response.status, | |
| message, | |
| requestId, | |
| }, | |
| }; | |
| } | |
| if (response.status === 204) { | |
| return { ok: true, data: undefined as T }; | |
| } | |
| return { ok: true, data: (await response.json()) as T }; | |
| } catch (error) { | |
| const aborted = error instanceof DOMException && error.name === "AbortError"; | |
| // The caller's own signal firing is a cancellation; ours firing is a timeout. | |
| const kind: ApiErrorKind = aborted ? (signal?.aborted ? "aborted" : "timeout") : "network"; | |
| return { ok: false, error: { kind, status: null, message: MESSAGES[kind] } }; | |
| } finally { | |
| clearTimeout(timer); | |
| } | |
| } | |
| function buildQuery(params: LogQueryParams): string { | |
| const search = new URLSearchParams(); | |
| if (params.dominant_emotion) search.set("dominant_emotion", params.dominant_emotion); | |
| if (params.start_date) search.set("start_date", params.start_date); | |
| if (params.end_date) search.set("end_date", params.end_date); | |
| if (params.q) search.set("q", params.q); | |
| if (params.skip !== undefined) search.set("skip", String(params.skip)); | |
| if (params.limit !== undefined) search.set("limit", String(params.limit)); | |
| const query = search.toString(); | |
| return query ? `?${query}` : ""; | |
| } | |
| export function createLog( | |
| input: EmotionLogInput, | |
| signal?: AbortSignal, | |
| ): Promise<Result<EmotionLog>> { | |
| return request("/api/logs", { method: "POST", body: input, auth: "ingest", signal }); | |
| } | |
| export function createLogsBulk( | |
| items: EmotionLogInput[], | |
| signal?: AbortSignal, | |
| ): Promise<Result<BulkCreateResult>> { | |
| return request("/api/logs/bulk", { | |
| method: "POST", | |
| body: { items }, | |
| auth: "ingest", | |
| signal, | |
| }); | |
| } | |
| export function listLogs( | |
| params: LogQueryParams = {}, | |
| signal?: AbortSignal, | |
| ): Promise<Result<LogsPage>> { | |
| return request(`/api/logs${buildQuery(params)}`, { signal }); | |
| } | |
| export function getLogStats( | |
| params: LogQueryParams = {}, | |
| signal?: AbortSignal, | |
| ): Promise<Result<LogStats>> { | |
| return request(`/api/logs/stats${buildQuery(params)}`, { signal }); | |
| } | |
| export function deleteLog(id: number, signal?: AbortSignal): Promise<Result<DeleteResult>> { | |
| return request(`/api/logs/${id}`, { method: "DELETE", signal }); | |
| } | |
| export function deleteAllLogs(signal?: AbortSignal): Promise<Result<DeleteResult>> { | |
| return request("/api/logs?confirm=true", { method: "DELETE", signal }); | |
| } | |
| export function checkHealth( | |
| baseUrl?: string, | |
| signal?: AbortSignal, | |
| ): Promise<Result<HealthResponse>> { | |
| return request("/api/health", { auth: "none", signal, timeoutMs: 5_000, baseUrl }); | |
| } | |
| /** Cheapest request that proves the API is reachable and answering. */ | |
| export function verifyConnection( | |
| baseUrl?: string, | |
| signal?: AbortSignal, | |
| ): Promise<Result<LogsPage>> { | |
| return request("/api/logs?limit=1", { signal, timeoutMs: 5_000, baseUrl }); | |
| } | |