Aynkhn's picture
Deploy FaceVision to Hugging Face Spaces
67af2df verified
Raw History Blame Contribute Delete
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 });
}