spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { t } from '@/i18n';4import { API_URL } from '@/lib/api';5import { API_VERSION, SITE_URL, routes } from '@/lib/site';6import { Section } from '@/components/data/section';7import { CodeBlock } from '@/components/explore/copy-button';8import { PageHeader } from '@/components/explore/page-header';9import { FooterCredits } from '@/components/layout/site-footer';10import { EndpointExplorer, type ExplorerEndpoint } from '@/components/platform/endpoint-explorer';1112export const revalidate = 3600;1314export const metadata: Metadata = {15 title: t('apiPage.title'),16 description: t('apiPage.sub'),17 alternates: { canonical: routes.api() },18};1920const BASE = `${SITE_URL}/api/v1`;2122type Row = [string, string, string]; // endpoint, description, group key23const ENDPOINTS: Row[] = [24 ['GET /health', t('apiPage.ep.health'), 'reference'],25 ['GET /countries?region=&income=&q=&sort=', t('apiPage.ep.countries'), 'countries'],26 ['GET /countries/{id}', t('apiPage.ep.country'), 'countries'],27 ['GET /countries/{id}/topics/{topic}', t('apiPage.ep.countryTopic'), 'countries'],28 ['GET /countries/{id}/series/{indicator}?from=&to=', t('apiPage.ep.countrySeries'), 'countries'],29 ['GET /countries/{id}/changes', t('apiPage.ep.countryChanges'), 'countries'],30 ['GET /countries/{id}/events', t('apiPage.ep.countryEvents'), 'countries'],31 ['GET /countries/{id}/similar?mode=', t('apiPage.ep.countrySimilar'), 'countries'],32 ['GET /countries/{id}/insights', t('apiPage.ep.countryInsights'), 'countries'],33 ['GET /countries/{id}/dna?reference=', t('apiPage.ep.countryDna'), 'countries'],34 ['GET /countries/{id}/story', t('apiPage.ep.story'), 'analytics'],35 ['GET /countries/{id}/quality', t('apiPage.ep.countryQuality'), 'analytics'],36 ['GET /countries/{id}/download.csv|json', t('apiPage.ep.countryDownload'), 'countries'],37 ['GET /indicators?topic=&q=&source=', t('apiPage.ep.indicators'), 'indicators'],38 ['GET /indicators/{slug}', t('apiPage.ep.indicator'), 'indicators'],39 ['GET /indicators/{slug}/map?year=', t('apiPage.ep.indicatorMap'), 'indicators'],40 ['GET /indicators/{slug}/trend?group=', t('apiPage.ep.indicatorTrend'), 'indicators'],41 ['GET /indicators/{slug}/frames?from=&to=', t('apiPage.ep.frames'), 'analytics'],42 ['GET /indicators/{slug}/distribution?year=&highlight=', t('apiPage.ep.distribution'), 'analytics'],43 ['GET /indicators/{slug}/related?limit=', t('apiPage.ep.related'), 'analytics'],44 ['GET /indicators/{slug}/quality', t('apiPage.ep.indicatorQuality'), 'analytics'],45 ['GET /indicators/{slug}/download.csv|json', t('apiPage.ep.indicatorDownload'), 'indicators'],46 ['GET /series?country=&indicator=', t('apiPage.ep.series'), 'indicators'],47 ['GET /rankings?topic=', t('apiPage.ep.rankings'), 'rankings'],48 ['GET /rankings/{indicator}?year=&group=&sort=', t('apiPage.ep.ranking'), 'rankings'],49 ['GET /rankings/{indicator}/history?countries=', t('apiPage.ep.rankingHistory'), 'rankings'],50 ['GET /rankings/{indicator}/race?from=&to=&top=', t('apiPage.ep.race'), 'analytics'],51 ['GET /compare?countries=&indicators=&mode=', t('apiPage.ep.compare'), 'rankings'],52 ['GET /compare/snapshot?countries=&topic=', t('apiPage.ep.compareSnapshot'), 'rankings'],53 ['GET /compare/download.csv|json?countries=&indicators=', t('apiPage.ep.download'), 'rankings'],54 ['GET /regions?kind=', t('apiPage.ep.regions'), 'rankings'],55 ['GET /regions/{slug}?indicator=', t('apiPage.ep.region'), 'rankings'],56 ['GET /regions/compare?a=&b=', t('apiPage.ep.regionsCompare'), 'analytics'],57 ['GET /pulse', t('apiPage.ep.pulse'), 'analytics'],58 ['GET /movers?window=&category=&kind=', t('apiPage.ep.movers'), 'analytics'],59 ['GET /extremes?window=&topic=', t('apiPage.ep.extremes'), 'analytics'],60 ['GET /scatter?x=&y=&size=&year=', t('apiPage.ep.scatter'), 'analytics'],61 ['GET /trajectory?x=&y=&size=&from=&to=', t('apiPage.ep.trajectory'), 'analytics'],62 ['GET /finder?f=slug:op:value&mode=', t('apiPage.ep.finder'), 'analytics'],63 ['GET /peers?y=&x=&year=', t('apiPage.ep.peers'), 'analytics'],64 ['GET /search?q=&type=', t('apiPage.ep.search'), 'reference'],65 ['GET /home', t('apiPage.ep.home'), 'reference'],66 ['GET /changes?kind=&indicator=&country=', t('apiPage.ep.changes'), 'reference'],67 ['GET /updates', t('apiPage.ep.updates'), 'analytics'],68 ['GET /sources', t('apiPage.ep.sources'), 'reference'],69 ['GET /sources/{id}', t('apiPage.ep.source'), 'reference'],70 ['GET /methodology', t('apiPage.ep.methodology'), 'reference'],71];72const GROUP_ORDER = ['countries', 'indicators', 'rankings', 'analytics', 'reference'] as const;7374const EXPLORER: ExplorerEndpoint[] = [75 { id: 'country', group: 'Countries', template: '/countries/{id}', summary: t('apiPage.ep.country'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }] },76 { id: 'series', group: 'Countries', template: '/countries/{id}/series/{indicator}', summary: t('apiPage.ep.countrySeries'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'CAN' }, { name: 'indicator', in: 'path', description: 'Indicator slug.', example: 'gdp-per-capita' }, { name: 'from', in: 'query', description: 'First year.', example: '2000' }, { name: 'to', in: 'query', description: 'Last year.' }, { name: 'include_alt', in: 'query', description: 'Also return values from lower-priority sources.', options: ['false', 'true'] }] },77 { id: 'similar', group: 'Countries', template: '/countries/{id}/similar', summary: t('apiPage.ep.countrySimilar'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }, { name: 'mode', in: 'query', description: 'Similarity mode.', options: ['overall', 'economic', 'demographic', 'energy', 'social'], example: 'overall' }, { name: 'limit', in: 'query', description: 'Peers returned (≤ 50).', example: '8' }] },78 { id: 'story', group: 'Countries', template: '/countries/{id}/story', summary: t('apiPage.ep.story'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }] },79 { id: 'quality', group: 'Countries', template: '/countries/{id}/quality', summary: t('apiPage.ep.countryQuality'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }] },80 { id: 'indicator', group: 'Indicators', template: '/indicators/{slug}', summary: t('apiPage.ep.indicator'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }] },81 { id: 'map', group: 'Indicators', template: '/indicators/{slug}/map', summary: t('apiPage.ep.indicatorMap'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }, { name: 'year', in: 'query', description: 'Year (default: latest year with ≥ 50 countries).', example: '2023' }, { name: 'nearest', in: 'query', description: 'Use each country’s latest value within 3 years.', options: ['false', 'true'] }] },82 { id: 'trend', group: 'Indicators', template: '/indicators/{slug}/trend', summary: t('apiPage.ep.indicatorTrend'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'gdp' }, { name: 'group', in: 'query', description: 'Group slug (world, oecd, g7, europe-central-asia…).', example: 'oecd' }] },83 { id: 'frames', group: 'Indicators', template: '/indicators/{slug}/frames', summary: t('apiPage.ep.frames'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'gdp-per-capita-ppp' }, { name: 'from', in: 'query', description: 'First year.', example: '1990' }, { name: 'to', in: 'query', description: 'Last year.' }] },84 { id: 'distribution', group: 'Indicators', template: '/indicators/{slug}/distribution', summary: t('apiPage.ep.distribution'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }, { name: 'highlight', in: 'query', description: 'Country to place on the distribution (ISO3).', example: 'CAN' }, { name: 'year', in: 'query', description: 'Year.' }] },85 { id: 'related', group: 'Indicators', template: '/indicators/{slug}/related', summary: t('apiPage.ep.related'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }, { name: 'limit', in: 'query', description: 'Rows.', example: '12' }] },86 { id: 'ranking', group: 'Rankings & comparisons', template: '/rankings/{indicator}', summary: t('apiPage.ep.ranking'), params: [{ name: 'indicator', in: 'path', description: 'Indicator slug.', example: 'gdp-per-capita' }, { name: 'year', in: 'query', description: 'Ranking year (nearest available).' }, { name: 'group', in: 'query', description: 'Group slug (default world).', example: 'world' }, { name: 'sort', in: 'query', description: 'Direction.', options: ['desc', 'asc'] }, { name: 'limit', in: 'query', description: 'Rows (≤ 300).', example: '10' }] },87 { id: 'race', group: 'Rankings & comparisons', template: '/rankings/{indicator}/race', summary: t('apiPage.ep.race'), params: [{ name: 'indicator', in: 'path', description: 'Indicator slug.', example: 'gdp' }, { name: 'from', in: 'query', description: 'First year.', example: '1960' }, { name: 'top', in: 'query', description: 'Top N per year.', example: '10' }] },88 { id: 'compare', group: 'Rankings & comparisons', template: '/compare', summary: t('apiPage.ep.compare'), params: [{ name: 'countries', in: 'query', description: 'Comma-separated ISO3 or slugs (2–8).', example: 'CAN,USA,FRA', required: true }, { name: 'indicators', in: 'query', description: 'Comma-separated slugs (1–8).', example: 'gdp-per-capita', required: true }, { name: 'mode', in: 'query', description: 'Transformation.', options: ['absolute', 'per-capita', 'index100', 'pct'] }, { name: 'from', in: 'query', description: 'First year.', example: '1990' }] },89 { id: 'regions-compare', group: 'Rankings & comparisons', template: '/regions/compare', summary: t('apiPage.ep.regionsCompare'), params: [{ name: 'a', in: 'query', description: 'Group slug.', example: 'g7', required: true }, { name: 'b', in: 'query', description: 'Group slug.', example: 'brics', required: true }] },90 { id: 'pulse', group: 'Analytics (1.1)', template: '/pulse', summary: t('apiPage.ep.pulse'), params: [] },91 { id: 'movers', group: 'Analytics (1.1)', template: '/movers', summary: t('apiPage.ep.movers'), params: [{ name: 'window', in: 'query', description: 'Years.', options: ['1', '5', '10'], example: '1' }, { name: 'category', in: 'query', description: 'Category.', options: ['all', 'economic', 'demographic', 'health', 'energy', 'climate', 'digital', 'housing', 'labor'] }, { name: 'kind', in: 'query', description: 'Kind filter.', options: ['all', 'improvement', 'deterioration', 'increase', 'decrease', 'record', 'reversal', 'acceleration', 'structural'] }, { name: 'limit', in: 'query', description: 'Rows.', example: '20' }] },92 { id: 'extremes', group: 'Analytics (1.1)', template: '/extremes', summary: t('apiPage.ep.extremes'), params: [{ name: 'window', in: 'query', description: 'Window.', options: ['1', '5', '10', '25', 'since1990'], example: '10' }, { name: 'topic', in: 'query', description: 'Topic id filter.' }] },93 { id: 'scatter', group: 'Analytics (1.1)', template: '/scatter', summary: t('apiPage.ep.scatter'), params: [{ name: 'x', in: 'query', description: 'X indicator slug.', example: 'gdp-per-capita-ppp', required: true }, { name: 'y', in: 'query', description: 'Y indicator slug.', example: 'life-expectancy', required: true }, { name: 'size', in: 'query', description: 'Bubble size indicator (or none).', example: 'population' }, { name: 'year', in: 'query', description: 'Year.' }, { name: 'group', in: 'query', description: 'Group slug.', example: 'world' }] },94 { id: 'trajectory', group: 'Analytics (1.1)', template: '/trajectory', summary: t('apiPage.ep.trajectory'), params: [{ name: 'x', in: 'query', description: 'X indicator slug.', example: 'gdp-per-capita-ppp', required: true }, { name: 'y', in: 'query', description: 'Y indicator slug.', example: 'life-expectancy', required: true }, { name: 'size', in: 'query', description: 'Bubble size indicator.', example: 'population' }, { name: 'from', in: 'query', description: 'First year.', example: '1990' }] },95 { id: 'finder', group: 'Analytics (1.1)', template: '/finder', summary: t('apiPage.ep.finder'), params: [{ name: 'f', in: 'query', description: 'Filter slug:op:value (ops gt gte lt lte eq between a..b). One filter here; the API accepts several f= parameters.', example: 'gdp-per-capita:gt:40000', required: true }, { name: 'mode', in: 'query', description: 'Combine filters.', options: ['and', 'or'] }, { name: 'region', in: 'query', description: 'Group slug.' }, { name: 'limit', in: 'query', description: 'Rows (≤ 218).', example: '50' }] },96 { id: 'peers', group: 'Analytics (1.1)', template: '/peers', summary: t('apiPage.ep.peers'), params: [{ name: 'y', in: 'query', description: 'Outcome indicator.', example: 'life-expectancy' }, { name: 'x', in: 'query', description: 'Explanatory indicator.', example: 'gdp-per-capita-ppp' }, { name: 'method', in: 'query', description: 'Fit.', options: ['theil-sen', 'ols'] }] },97 { id: 'search', group: 'Reference', template: '/search', summary: t('apiPage.ep.search'), params: [{ name: 'q', in: 'query', description: 'Query (try "compare canada usa" or "rank gdp").', example: 'canada gdp', required: true }, { name: 'limit', in: 'query', description: 'Hits.', example: '8' }] },98 { id: 'changes', group: 'Reference', template: '/changes', summary: t('apiPage.ep.changes'), params: [{ name: 'kind', in: 'query', description: 'Change kind.', options: ['yoy_jump', 'yoy_drop', 'record_high', 'record_low', 'n_year_high', 'n_year_low', 'sign_flip', 'accelerating', 'decelerating', 'structural_break', 'trend_reversal', 'volatility_spike'] }, { name: 'topic', in: 'query', description: 'Topic id.' }, { name: 'min_severity', in: 'query', description: '0–1.', example: '0.7' }, { name: 'limit', in: 'query', description: 'Rows.', example: '10' }] },99 { id: 'updates', group: 'Reference', template: '/updates', summary: t('apiPage.ep.updates'), params: [] },100 { id: 'health', group: 'Reference', template: '/health', summary: t('apiPage.ep.health'), params: [] },101];102103const NEW_IN_11 = ['pulse', 'movers', 'extremes', 'scatter', 'trajectory', 'finder', 'peers', 'related', 'distribution', 'frames', 'indicatorQuality', 'race', 'regionsCompare', 'story', 'countryQuality', 'updates'] as const;104const NEW_PATHS: Record<(typeof NEW_IN_11)[number], string> = {105 pulse: '/pulse',106 movers: '/movers',107 extremes: '/extremes',108 scatter: '/scatter',109 trajectory: '/trajectory',110 finder: '/finder',111 peers: '/peers',112 related: '/indicators/{slug}/related',113 distribution: '/indicators/{slug}/distribution',114 frames: '/indicators/{slug}/frames',115 indicatorQuality: '/indicators/{slug}/quality',116 race: '/rankings/{indicator}/race',117 regionsCompare: '/regions/compare',118 story: '/countries/{id}/story',119 countryQuality: '/countries/{id}/quality',120 updates: '/updates',121};122123const PROVENANCE_FIELDS: Array<[string, string]> = [124 ['source / source_name', 'Connector id (worldbank, imf, oecd, eurostat, who, fred, owid, bis, ilo) and its display name.'],125 ['dataset, series_code', 'Dataset and series inside the source (WDI NY.GDP.PCAP.CD, WEO NGDPDPC, OWID co2_per_capita…).'],126 ['retrieved_at', 'When the pipeline fetched the payload (UTC).'],127 ['source_updated_at', 'Last update advertised by the source; null when it publishes none.'],128 ['url', 'Deep link to the series at the source, with the country when the portal supports it.'],129 ['transform', 'Expression applied at normalisation (e.g. x*1e9), null if none.'],130 ['licence', 'Licence of the source for this series.'],131];132133async function liveVersion(): Promise<string | null> {134 try {135 const res = await fetch(`${API_URL.replace(/\/$/, '')}/api/v1/health`, { headers: { accept: 'application/json' }, next: { revalidate: 600 } });136 if (!res.ok) return null;137 const j = (await res.json()) as { run_id?: string | null };138 return j.run_id ?? null;139 } catch {140 return null;141 }142}143144export default async function ApiPage() {145 const runId = await liveVersion();146 return (147 <>148 <PageHeader149 title={t('apiPage.title')}150 lede={t('apiPage.sub')}151 eyebrow={152 <span className="inline-flex items-center gap-2">153 <span className="badge border-accent/40 bg-accent-soft text-accent">{t('apiPage.version', { v: API_VERSION })}</span>154 {runId ? <span className="tnum normal-case tracking-normal">{t('site.footer.build', { run: runId })}</span> : null}155 </span>156 }157 />158 <p className="max-w-prose text-base leading-relaxed text-ink-2">{t('apiPage.intro')}</p>159 <dl className="mt-5 grid gap-x-8 gap-y-3 border-y border-rule py-4 text-sm sm:grid-cols-3">160 <div>161 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.base')}</dt>162 <dd className="mt-1 break-all font-mono text-ink">{BASE}</dd>163 </div>164 <div>165 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.swagger')}</dt>166 <dd className="mt-1">167 <a href={routes.apiSwagger()} className="inline-flex min-h-[32px] items-center font-mono text-accent hover:underline">168 {routes.apiSwagger()}169 </a>170 </dd>171 </div>172 <div>173 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.openapi')}</dt>174 <dd className="mt-1">175 <a href={routes.apiOpenapi()} className="inline-flex min-h-[32px] items-center font-mono text-accent hover:underline">176 {routes.apiOpenapi()}177 </a>178 </dd>179 </div>180 </dl>181182 <Section id="explorer" title={t('apiPage.explorer.title')} subtitle={t('apiPage.explorer.sub')} className="border-t-0">183 <EndpointExplorer endpoints={EXPLORER} base={BASE} />184 </Section>185186 <Section id="new" title={t('apiPage.new.title')} subtitle={t('apiPage.new.sub')}>187 <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">188 {NEW_IN_11.map((k) => (189 <li key={k} className="border-t border-rule py-2.5">190 <code className="block break-all font-mono text-xs text-ink">GET {NEW_PATHS[k]}</code>191 <span className="mt-0.5 block text-sm text-ink-2">{t(`apiPage.ep.${k}` as 'apiPage.ep.pulse')}</span>192 </li>193 ))}194 </ul>195 </Section>196197 <Section id="endpoints" title={t('apiPage.endpoints.title')} subtitle={t('apiPage.endpoints.sub')}>198 {GROUP_ORDER.map((g) => (199 <div key={g} className="mb-6">200 <h3 className="eyebrow mb-1">{t(`apiPage.group.${g}` as 'apiPage.group.countries')}</h3>201 <table className="w-full border-collapse text-sm">202 <thead className="sr-only">203 <tr>204 <th scope="col">{t('apiPage.endpoint')}</th>205 <th scope="col">{t('apiPage.describes')}</th>206 </tr>207 </thead>208 <tbody className="divide-y divide-rule border-y border-rule">209 {ENDPOINTS.filter((e) => e[2] === g).map(([ep, desc]) => (210 <tr key={ep}>211 <td className="py-2 pr-3 align-top md:w-[46%]">212 <code className="break-all font-mono text-xs text-ink">{ep}</code>213 </td>214 <td className="py-2 align-top text-ink-2">{desc}</td>215 </tr>216 ))}217 </tbody>218 </table>219 </div>220 ))}221 </Section>222223 <Section id="provenance" title={t('apiPage.provenance.title')} subtitle={t('apiPage.provenance.sub')}>224 <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.provenance.text')}</p>225 <CodeBlock226 className="mt-3 max-w-3xl"227 lang="json"228 code={JSON.stringify(229 {230 value: 55697.66,231 period: '2025-01-01',232 year: 2025,233 formatted: 'US$55.7k',234 status: 'imported',235 provenance: { source: 'worldbank', source_name: 'World Bank', dataset: 'WDI', series_code: 'NY.GDP.PCAP.CD', retrieved_at: '2026-09-11T06:49:30Z', source_updated_at: '2026-07-13T00:00:00Z', url: 'https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA', transform: null, licence: 'CC BY 4.0' },236 },237 null,238 2,239 )}240 />241 <table className="mt-4 w-full max-w-3xl border-collapse text-sm">242 <thead>243 <tr className="border-b border-rule text-left text-xs text-ink-3">244 <th scope="col" className="py-1.5 pr-3 font-medium">{t('apiPage.field')}</th>245 <th scope="col" className="py-1.5 font-medium">{t('apiPage.meaning')}</th>246 </tr>247 </thead>248 <tbody className="divide-y divide-rule">249 {PROVENANCE_FIELDS.map(([f, d]) => (250 <tr key={f}>251 <td className="py-2 pr-3 align-top font-mono text-xs text-ink">{f}</td>252 <td className="py-2 align-top text-ink-2">{d}</td>253 </tr>254 ))}255 </tbody>256 </table>257 </Section>258259 <div className="grid gap-x-10 lg:grid-cols-2">260 <Section id="limits" title={t('apiPage.limits.title')}>261 <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.limits.text')}</p>262 </Section>263 <Section id="errors" title={t('apiPage.errors.title')}>264 <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.errors.text')}</p>265 </Section>266 </div>267268 <Section id="licence" title={t('apiPage.licence.title')}>269 <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.licence.text')}</p>270 <p className="mt-2 text-sm">271 <Link href={routes.sources()} className="text-accent hover:underline">272 {t('sources.title')} →273 </Link>274 <span className="mx-2 text-ink-3">·</span>275 <Link href={routes.methodology()} className="text-accent hover:underline">276 {t('method.title')} →277 </Link>278 <span className="mx-2 text-ink-3">·</span>279 <Link href={routes.download()} className="text-accent hover:underline">280 {t('download.title')} →281 </Link>282 </p>283 <div className="mt-6 text-sm text-ink-2">284 <span className="mr-2 font-medium text-ink">{t('apiPage.contact')}</span>285 <FooterCredits className="mt-1 inline text-xs" />286 </div>287 </Section>288 </>289 );290}291