spb/countryatlas
Public
1import 'server-only';2import type {3 ChangesResponse,4 CountriesResponse,5 CountryResponse,6 CountryTopicResponse,7 DNAResponse,8 HealthResponse,9 HomeResponse,10 InsightsResponse,11 MapResponse,12 Problem,13 RankingResponse,14 SearchResponse,15 SeriesResponse,16 SimilarResponse,17 SimilarityMode,18} from './types';1920/**21 * Typed fetch wrapper for the CountryAtlas API (server components only — client code calls the same-origin22 * `/api/v1/*` rewrite through `src/lib/client-api.ts`).23 *24 * - default cache: ISR `next: { revalidate: 900 }` (15 min); search is `no-store`.25 * - non-2xx → `ApiError` (status + RFC 7807 problem body when available); network failure → status 0.26 * - 503 "Data not built yet" (no snapshot) → `ApiError.notBuilt`; pages render <NotBuiltState/> — never crash.27 * - `safe(promise)` turns any error into `null` for the optional panels fetched in parallel with Promise.all.28 */2930export const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8291';31const BASE = `${API_URL.replace(/\/$/, '')}/api/v1`;3233export class ApiError extends Error {34 readonly status: number;35 readonly problem: Problem | null;36 readonly path: string;37 constructor(status: number, path: string, problem: Problem | null, message?: string) {38 super(message ?? problem?.detail ?? problem?.title ?? `API ${status} on ${path}`);39 this.name = 'ApiError';40 this.status = status;41 this.problem = problem;42 this.path = path;43 }44 /** The snapshot does not exist yet (fresh deployment, pipeline still running) — or the API is down. */45 get notBuilt(): boolean {46 return this.status === 503 || this.status === 0;47 }48 get notFound(): boolean {49 return this.status === 404;50 }51}5253export interface FetchOptions {54 /** Seconds; `false` → `cache: 'no-store'`. Default 900. */55 revalidate?: number | false;56 tags?: string[];57}5859type Query = Record<string, string | number | boolean | null | undefined>;6061function qs(query?: Query): string {62 if (!query) return '';63 const p = new URLSearchParams();64 for (const [k, v] of Object.entries(query)) {65 if (v === undefined || v === null || v === '') continue;66 p.set(k, String(v));67 }68 const s = p.toString();69 return s ? `?${s}` : '';70}7172export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> {73 const url = `${BASE}${path}${qs(query)}`;74 const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } };75 if (opts.revalidate === false) init.cache = 'no-store';76 else init.next = { revalidate: opts.revalidate ?? 900, tags: opts.tags };7778 let res: Response;79 try {80 res = await fetch(url, init);81 } catch (e) {82 throw new ApiError(0, path, null, `API unreachable at ${BASE} (${(e as Error).message})`);83 }84 if (!res.ok) {85 let problem: Problem | null = null;86 try {87 const body = (await res.json()) as unknown;88 if (body && typeof body === 'object' && 'status' in body && 'title' in body) problem = body as Problem;89 else if (body && typeof body === 'object' && 'detail' in body) problem = { title: String((body as { detail: unknown }).detail), status: res.status };90 } catch {91 /* non-JSON error body */92 }93 throw new ApiError(res.status, path, problem);94 }95 return (await res.json()) as T;96}9798/** Resolve to `null` on any API error (optional panels). */99export async function safe<T>(p: Promise<T>): Promise<T | null> {100 try {101 return await p;102 } catch {103 return null;104 }105}106107/** True when the error means "render the calm not-built state" rather than an error boundary. */108export function isNotBuilt(e: unknown): boolean {109 return e instanceof ApiError && e.notBuilt;110}111export function isNotFound(e: unknown): boolean {112 return e instanceof ApiError && e.notFound;113}114115// ---------------------------------------------------------------------------------------------- endpoints (§8)116117export const api = {118 health: () => request<HealthResponse>('/health', undefined, { revalidate: 60 }),119120 home: () => request<HomeResponse>('/home'),121122 countries: (q: { region?: string; income?: string; q?: string; sort?: 'name' | 'population' | 'gdp' | 'gdp_per_capita' | 'coverage'; kind?: string; limit?: number; offset?: number } = {}) =>123 request<CountriesResponse>('/countries', { limit: 1000, ...q }),124125 country: (id: string) => request<CountryResponse>(`/countries/${encodeURIComponent(id)}`),126127 countryTopic: (id: string, topic: string) => request<CountryTopicResponse>(`/countries/${encodeURIComponent(id)}/topics/${encodeURIComponent(topic)}`),128129 countrySeries: (id: string, indicator: string, q: { from?: number; to?: number; freq?: Frequency; include_forecast?: boolean; include_alt?: boolean } = {}) =>130 request<SeriesResponse>(`/countries/${encodeURIComponent(id)}/series/${encodeURIComponent(indicator)}`, q),131132 countryChanges: (id: string, limit = 12, kind?: string) => request<ChangesResponse>(`/countries/${encodeURIComponent(id)}/changes`, { limit, kind }),133134 countryEvents: (id: string, limit = 40, q: { kind?: string; indicator?: string } = {}) =>135 request<ChangesResponse>(`/countries/${encodeURIComponent(id)}/events`, { limit, ...q }),136137 countrySimilar: (id: string, mode: SimilarityMode | string = 'overall', limit = 12) =>138 request<SimilarResponse>(`/countries/${encodeURIComponent(id)}/similar`, { mode, limit }),139140 countryInsights: (id: string) => request<InsightsResponse>(`/countries/${encodeURIComponent(id)}/insights`),141142 countryDna: (id: string) => request<DNAResponse>(`/countries/${encodeURIComponent(id)}/dna`),143144 indicatorMap: (slug: string, q: { year?: number; nearest?: boolean } = {}) => request<MapResponse>(`/indicators/${encodeURIComponent(slug)}/map`, q),145146 ranking: (slug: string, q: { year?: number; group?: string; sort?: 'asc' | 'desc'; limit?: number; offset?: number; sparkline?: boolean } = {}) =>147 request<RankingResponse>(`/rankings/${encodeURIComponent(slug)}`, q),148149 search: (q: string, limit = 12, type?: string) => request<SearchResponse>('/search', { q, limit, type }, { revalidate: false }),150151 changes: (q: { limit?: number; kind?: string } = {}) => request<ChangesResponse>('/changes', q),152};153154type Frequency = 'A' | 'Q' | 'M';155export type Api = typeof api;156