← back to Govarbitrage
apps/mobile/lib/api.ts
146 lines
/**
* GovArbitrage typed API client.
*
* Auth is OPTIONAL. The default server's read API is public, so a fresh install
* sends NO Authorization header and gets the same data any browser or curl gets
* — identical behavior across every client. A header is only added when the user
* takes an explicit, visible action:
* • Sign in with Apple → Authorization: Bearer <app session JWT>
* • Enter a username + password in Settings (to point the app at their own
* self-hosted server) → Authorization: Basic base64(user:pass)
* With neither, requests are anonymous.
*/
import { loadSettings, loadAppleAccount } from "./settings";
import { authHeadersFor } from "./auth-headers";
import type {
ListingDetail,
ListingsQueryParams,
ListingsResponse,
} from "./types";
import { normalizeListingsResponse, normalizeListingDetail } from "./validate";
import { ApiError } from "./api-error";
// ── Internal helpers ──────────────────────────────────────────────────────────
async function getAuthHeaders(): Promise<HeadersInit> {
const account = await loadAppleAccount();
const { username, password } = await loadSettings();
return authHeadersFor({ appleToken: account?.token, username, password });
}
interface FetchOptions {
signal?: AbortSignal;
}
const REQUEST_TIMEOUT_MS = 15_000;
async function apiFetch<T>(path: string, opts: FetchOptions = {}): Promise<T> {
const { baseUrl } = await loadSettings();
const authHeaders = await getAuthHeaders();
const url = `${baseUrl.replace(/\/$/, "")}${path}`;
// Bound every request so a stalled connection can't leave the UI spinning
// forever. A timeout throws a real ApiError (surfaced to the user); a genuine
// caller-abort keeps its AbortError (callers treat that as silent navigation).
const controller = new AbortController();
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
controller.abort();
}, REQUEST_TIMEOUT_MS);
if (opts.signal) {
if (opts.signal.aborted) controller.abort();
else opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
}
try {
const res = await fetch(url, {
headers: {
Accept: "application/json",
...authHeaders,
},
signal: controller.signal,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
const msg = body || res.statusText || String(res.status);
throw new ApiError(res.status, msg);
}
return (await res.json()) as T;
} catch (err) {
if (timedOut) {
throw new ApiError(0, "Request timed out. Check your connection or the server URL in Settings.");
}
throw err;
} finally {
clearTimeout(timer);
}
}
// ── Error type ────────────────────────────────────────────────────────────────
// Re-exported from ./api-error so existing `import { ApiError } from "./api"`
// callers keep working while validate.ts imports it without a circular dep.
export { ApiError } from "./api-error";
// ── Listings ──────────────────────────────────────────────────────────────────
export async function fetchListings(
params: ListingsQueryParams = {},
opts: FetchOptions = {}
): Promise<ListingsResponse> {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.source) qs.set("source", params.source);
if (params.category) qs.set("category", params.category);
if (params.condition) qs.set("condition", params.condition);
if (params.risk) qs.set("risk", params.risk);
if (params.closingWithinHours != null)
qs.set("closingWithinHours", String(params.closingWithinHours));
if (params.sort) qs.set("sort", params.sort);
if (params.dir) qs.set("dir", params.dir);
if (params.page != null) qs.set("page", String(params.page));
if (params.pageSize != null) qs.set("pageSize", String(params.pageSize));
if (params.profile) qs.set("profile", params.profile);
const query = qs.toString();
const raw = await apiFetch<unknown>(
`/api/listings${query ? `?${query}` : ""}`,
opts
);
return normalizeListingsResponse(raw);
}
export async function fetchListing(
id: string,
opts: FetchOptions = {}
): Promise<ListingDetail> {
const raw = await apiFetch<unknown>(`/api/listings/${encodeURIComponent(id)}`, opts);
return normalizeListingDetail(raw);
}
// ── Connection test ───────────────────────────────────────────────────────────
export interface ConnectionTestResult {
ok: boolean;
latencyMs: number;
tier?: string;
error?: string;
}
export async function testConnection(): Promise<ConnectionTestResult> {
const t0 = Date.now();
try {
const data = await fetchListings({ pageSize: 1 });
return { ok: true, latencyMs: Date.now() - t0, tier: data.tier };
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - t0,
error: err instanceof Error ? err.message : String(err),
};
}
}