import 'server-only'; import type { ChangesResponse, CountriesResponse, CountryResponse, CountryTopicResponse, DNAResponse, HealthResponse, HomeResponse, InsightsResponse, MapResponse, Problem, RankingResponse, SearchResponse, SeriesResponse, SimilarResponse, SimilarityMode, } from './types'; /** * Typed fetch wrapper for the CountryAtlas API (server components only — client code calls the same-origin * `/api/v1/*` rewrite through `src/lib/client-api.ts`). * * - default cache: ISR `next: { revalidate: 900 }` (15 min); search is `no-store`. * - non-2xx → `ApiError` (status + RFC 7807 problem body when available); network failure → status 0. * - 503 "Data not built yet" (no snapshot) → `ApiError.notBuilt`; pages render — never crash. * - `safe(promise)` turns any error into `null` for the optional panels fetched in parallel with Promise.all. */ export const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8291'; const BASE = `${API_URL.replace(/\/$/, '')}/api/v1`; /** Shared secret (same env var on both processes) that exempts server-side renders from the per-IP rate limit. */ const INTERNAL_TOKEN = process.env.CA_ADMIN_TOKEN ?? ''; export class ApiError extends Error { readonly status: number; readonly problem: Problem | null; readonly path: string; constructor(status: number, path: string, problem: Problem | null, message?: string) { super(message ?? problem?.detail ?? problem?.title ?? `API ${status} on ${path}`); this.name = 'ApiError'; this.status = status; this.problem = problem; this.path = path; } /** The data service is unavailable right now: no snapshot yet (503), unreachable (0), rate-limited (429) or failing (5xx). * Pages render the calm "data is being prepared" state for all of these instead of crashing (and never fail a build). */ get notBuilt(): boolean { return this.status === 503 || this.status === 0 || this.status === 429 || this.status >= 500; } get notFound(): boolean { return this.status === 404; } } export interface FetchOptions { /** Seconds; `false` → `cache: 'no-store'`. Default 900. */ revalidate?: number | false; tags?: string[]; } type Query = Record; function qs(query?: Query): string { if (!query) return ''; const p = new URLSearchParams(); for (const [k, v] of Object.entries(query)) { if (v === undefined || v === null || v === '') continue; p.set(k, String(v)); } const s = p.toString(); return s ? `?${s}` : ''; } export async function request(path: string, query?: Query, opts: FetchOptions = {}): Promise { const url = `${BASE}${path}${qs(query)}`; const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: INTERNAL_TOKEN ? { accept: 'application/json', 'x-countryatlas-internal': INTERNAL_TOKEN } : { accept: 'application/json' }, }; if (opts.revalidate === false) init.cache = 'no-store'; else init.next = { revalidate: opts.revalidate ?? 900, tags: opts.tags }; let res: Response; try { res = await fetch(url, init); if (res.status === 429) { // One polite retry after the advertised delay (capped at 2 s) before surfacing the error. const wait = Math.min(2000, Math.max(250, Number(res.headers.get('retry-after') ?? 1) * 1000)); await new Promise((r) => setTimeout(r, wait)); res = await fetch(url, init); } } catch (e) { throw new ApiError(0, path, null, `API unreachable at ${BASE} (${(e as Error).message})`); } if (!res.ok) { let problem: Problem | null = null; try { const body = (await res.json()) as unknown; if (body && typeof body === 'object' && 'status' in body && 'title' in body) problem = body as Problem; else if (body && typeof body === 'object' && 'detail' in body) problem = { title: String((body as { detail: unknown }).detail), status: res.status }; } catch { /* non-JSON error body */ } throw new ApiError(res.status, path, problem); } return (await res.json()) as T; } /** Resolve to `null` on any API error (optional panels). */ export async function safe(p: Promise): Promise { try { return await p; } catch { return null; } } /** True when the error means "render the calm not-built state" rather than an error boundary. */ export function isNotBuilt(e: unknown): boolean { return e instanceof ApiError && e.notBuilt; } export function isNotFound(e: unknown): boolean { return e instanceof ApiError && e.notFound; } // ---------------------------------------------------------------------------------------------- endpoints (§8) export const api = { health: () => request('/health', undefined, { revalidate: 60 }), home: () => request('/home'), countries: (q: { region?: string; income?: string; q?: string; sort?: 'name' | 'population' | 'gdp' | 'gdp_per_capita' | 'coverage'; kind?: string; limit?: number; offset?: number } = {}) => request('/countries', { limit: 1000, ...q }), country: (id: string) => request(`/countries/${encodeURIComponent(id)}`), countryTopic: (id: string, topic: string) => request(`/countries/${encodeURIComponent(id)}/topics/${encodeURIComponent(topic)}`), countrySeries: (id: string, indicator: string, q: { from?: number; to?: number; freq?: Frequency; include_forecast?: boolean; include_alt?: boolean } = {}) => request(`/countries/${encodeURIComponent(id)}/series/${encodeURIComponent(indicator)}`, q), countryChanges: (id: string, limit = 12, kind?: string) => request(`/countries/${encodeURIComponent(id)}/changes`, { limit, kind }), countryEvents: (id: string, limit = 40, q: { kind?: string; indicator?: string } = {}) => request(`/countries/${encodeURIComponent(id)}/events`, { limit, ...q }), countrySimilar: (id: string, mode: SimilarityMode | string = 'overall', limit = 12) => request(`/countries/${encodeURIComponent(id)}/similar`, { mode, limit }), countryInsights: (id: string) => request(`/countries/${encodeURIComponent(id)}/insights`), countryDna: (id: string) => request(`/countries/${encodeURIComponent(id)}/dna`), indicatorMap: (slug: string, q: { year?: number; nearest?: boolean } = {}) => request(`/indicators/${encodeURIComponent(slug)}/map`, q), ranking: (slug: string, q: { year?: number; group?: string; sort?: 'asc' | 'desc'; limit?: number; offset?: number; sparkline?: boolean } = {}) => request(`/rankings/${encodeURIComponent(slug)}`, q), search: (q: string, limit = 12, type?: string) => request('/search', { q, limit, type }, { revalidate: false }), changes: (q: { limit?: number; kind?: string } = {}) => request('/changes', q), }; type Frequency = 'A' | 'Q' | 'M'; export type Api = typeof api;