SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
6.9 KB · 167 lines typescript
Raw Blame History
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`;32/** Shared secret (same env var on both processes) that exempts server-side renders from the per-IP rate limit. */33const INTERNAL_TOKEN = process.env.CA_ADMIN_TOKEN ?? '';3435export class ApiError extends Error {36  readonly status: number;37  readonly problem: Problem | null;38  readonly path: string;39  constructor(status: number, path: string, problem: Problem | null, message?: string) {40    super(message ?? problem?.detail ?? problem?.title ?? `API ${status} on ${path}`);41    this.name = 'ApiError';42    this.status = status;43    this.problem = problem;44    this.path = path;45  }46  /** The data service is unavailable right now: no snapshot yet (503), unreachable (0), rate-limited (429) or failing (5xx).47   *  Pages render the calm "data is being prepared" state for all of these instead of crashing (and never fail a build). */48  get notBuilt(): boolean {49    return this.status === 503 || this.status === 0 || this.status === 429 || this.status >= 500;50  }51  get notFound(): boolean {52    return this.status === 404;53  }54}5556export interface FetchOptions {57  /** Seconds; `false` → `cache: 'no-store'`. Default 900. */58  revalidate?: number | false;59  tags?: string[];60}6162type Query = Record<string, string | number | boolean | null | undefined>;6364function qs(query?: Query): string {65  if (!query) return '';66  const p = new URLSearchParams();67  for (const [k, v] of Object.entries(query)) {68    if (v === undefined || v === null || v === '') continue;69    p.set(k, String(v));70  }71  const s = p.toString();72  return s ? `?${s}` : '';73}7475export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> {76  const url = `${BASE}${path}${qs(query)}`;77  const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = {78    headers: INTERNAL_TOKEN ? { accept: 'application/json', 'x-countryatlas-internal': INTERNAL_TOKEN } : { accept: 'application/json' },79  };80  if (opts.revalidate === false) init.cache = 'no-store';81  else init.next = { revalidate: opts.revalidate ?? 900, tags: opts.tags };8283  let res: Response;84  try {85    res = await fetch(url, init);86    if (res.status === 429) {87      // One polite retry after the advertised delay (capped at 2 s) before surfacing the error.88      const wait = Math.min(2000, Math.max(250, Number(res.headers.get('retry-after') ?? 1) * 1000));89      await new Promise((r) => setTimeout(r, wait));90      res = await fetch(url, init);91    }92  } catch (e) {93    throw new ApiError(0, path, null, `API unreachable at ${BASE} (${(e as Error).message})`);94  }95  if (!res.ok) {96    let problem: Problem | null = null;97    try {98      const body = (await res.json()) as unknown;99      if (body && typeof body === 'object' && 'status' in body && 'title' in body) problem = body as Problem;100      else if (body && typeof body === 'object' && 'detail' in body) problem = { title: String((body as { detail: unknown }).detail), status: res.status };101    } catch {102      /* non-JSON error body */103    }104    throw new ApiError(res.status, path, problem);105  }106  return (await res.json()) as T;107}108109/** Resolve to `null` on any API error (optional panels). */110export async function safe<T>(p: Promise<T>): Promise<T | null> {111  try {112    return await p;113  } catch {114    return null;115  }116}117118/** True when the error means "render the calm not-built state" rather than an error boundary. */119export function isNotBuilt(e: unknown): boolean {120  return e instanceof ApiError && e.notBuilt;121}122export function isNotFound(e: unknown): boolean {123  return e instanceof ApiError && e.notFound;124}125126// ---------------------------------------------------------------------------------------------- endpoints (§8)127128export const api = {129  health: () => request<HealthResponse>('/health', undefined, { revalidate: 60 }),130131  home: () => request<HomeResponse>('/home'),132133  countries: (q: { region?: string; income?: string; q?: string; sort?: 'name' | 'population' | 'gdp' | 'gdp_per_capita' | 'coverage'; kind?: string; limit?: number; offset?: number } = {}) =>134    request<CountriesResponse>('/countries', { limit: 1000, ...q }),135136  country: (id: string) => request<CountryResponse>(`/countries/${encodeURIComponent(id)}`),137138  countryTopic: (id: string, topic: string) => request<CountryTopicResponse>(`/countries/${encodeURIComponent(id)}/topics/${encodeURIComponent(topic)}`),139140  countrySeries: (id: string, indicator: string, q: { from?: number; to?: number; freq?: Frequency; include_forecast?: boolean; include_alt?: boolean } = {}) =>141    request<SeriesResponse>(`/countries/${encodeURIComponent(id)}/series/${encodeURIComponent(indicator)}`, q),142143  countryChanges: (id: string, limit = 12, kind?: string) => request<ChangesResponse>(`/countries/${encodeURIComponent(id)}/changes`, { limit, kind }),144145  countryEvents: (id: string, limit = 40, q: { kind?: string; indicator?: string } = {}) =>146    request<ChangesResponse>(`/countries/${encodeURIComponent(id)}/events`, { limit, ...q }),147148  countrySimilar: (id: string, mode: SimilarityMode | string = 'overall', limit = 12) =>149    request<SimilarResponse>(`/countries/${encodeURIComponent(id)}/similar`, { mode, limit }),150151  countryInsights: (id: string) => request<InsightsResponse>(`/countries/${encodeURIComponent(id)}/insights`),152153  countryDna: (id: string) => request<DNAResponse>(`/countries/${encodeURIComponent(id)}/dna`),154155  indicatorMap: (slug: string, q: { year?: number; nearest?: boolean } = {}) => request<MapResponse>(`/indicators/${encodeURIComponent(slug)}/map`, q),156157  ranking: (slug: string, q: { year?: number; group?: string; sort?: 'asc' | 'desc'; limit?: number; offset?: number; sparkline?: boolean } = {}) =>158    request<RankingResponse>(`/rankings/${encodeURIComponent(slug)}`, q),159160  search: (q: string, limit = 12, type?: string) => request<SearchResponse>('/search', { q, limit, type }, { revalidate: false }),161162  changes: (q: { limit?: number; kind?: string } = {}) => request<ChangesResponse>('/changes', q),163};164165type Frequency = 'A' | 'Q' | 'M';166export type Api = typeof api;167