spb/countryatlas
Public
1import type { Metadata } from 'next';2import Link from 'next/link';3import { t } from '@/i18n';4import { API_URL } from '@/lib/api';5import { 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';1011export const revalidate = 3600;1213export const metadata: Metadata = {14 title: t('apiPage.title'),15 description: t('apiPage.sub'),16 alternates: { canonical: routes.api() },17};1819const BASE = `${SITE_URL}/api/v1`;2021const ENDPOINTS: Array<[string, string]> = [22 ['GET /health', t('apiPage.ep.health')],23 ['GET /countries?region=&income=&q=&sort=', t('apiPage.ep.countries')],24 ['GET /countries/{id}', t('apiPage.ep.country')],25 ['GET /countries/{id}/topics/{topic}', t('apiPage.ep.countryTopic')],26 ['GET /countries/{id}/series/{indicator}?from=&to=', t('apiPage.ep.countrySeries')],27 ['GET /countries/{id}/changes', t('apiPage.ep.countryChanges')],28 ['GET /countries/{id}/events', t('apiPage.ep.countryEvents')],29 ['GET /countries/{id}/similar?mode=', t('apiPage.ep.countrySimilar')],30 ['GET /countries/{id}/insights', t('apiPage.ep.countryInsights')],31 ['GET /countries/{id}/dna', t('apiPage.ep.countryDna')],32 ['GET /countries/{id}/download.csv|json', t('apiPage.ep.countryDownload')],33 ['GET /indicators?topic=&q=&source=', t('apiPage.ep.indicators')],34 ['GET /indicators/{slug}', t('apiPage.ep.indicator')],35 ['GET /indicators/{slug}/map?year=', t('apiPage.ep.indicatorMap')],36 ['GET /indicators/{slug}/trend?group=', t('apiPage.ep.indicatorTrend')],37 ['GET /indicators/{slug}/download.csv|json', t('apiPage.ep.indicatorDownload')],38 ['GET /series?country=&indicator=', t('apiPage.ep.series')],39 ['GET /rankings?topic=', t('apiPage.ep.rankings')],40 ['GET /rankings/{indicator}?year=&group=&sort=', t('apiPage.ep.ranking')],41 ['GET /rankings/{indicator}/history?countries=', t('apiPage.ep.rankingHistory')],42 ['GET /compare?countries=&indicators=&mode=', t('apiPage.ep.compare')],43 ['GET /compare/snapshot?countries=&topic=', t('apiPage.ep.compareSnapshot')],44 ['GET /regions?kind=', t('apiPage.ep.regions')],45 ['GET /regions/{slug}?indicator=', t('apiPage.ep.region')],46 ['GET /search?q=&type=', t('apiPage.ep.search')],47 ['GET /home', t('apiPage.ep.home')],48 ['GET /changes?kind=&indicator=&country=', t('apiPage.ep.changes')],49 ['GET /sources', t('apiPage.ep.sources')],50 ['GET /sources/{id}', t('apiPage.ep.source')],51 ['GET /methodology', t('apiPage.ep.methodology')],52];5354const PROVENANCE_FIELDS: Array<[string, string]> = [55 ['source / source_name', 'Connector id (worldbank, imf, oecd, eurostat, who, fred, owid, bis, ilo) and its display name.'],56 ['dataset, series_code', 'Dataset and series inside the source (WDI NY.GDP.PCAP.CD, WEO NGDPDPC, OWID co2_per_capita…).'],57 ['retrieved_at', 'When the pipeline fetched the payload (UTC).'],58 ['source_updated_at', 'Last update advertised by the source; null when it publishes none.'],59 ['url', 'Deep link to the series at the source, with the country when the portal supports it.'],60 ['transform', 'Expression applied at normalisation (e.g. x*1e9), null if none.'],61 ['licence', 'Licence of the source for this series.'],62];6364type Example = { key: string; path: string; pick: (j: unknown) => unknown };65const EXAMPLES: Example[] = [66 {67 key: 'country',68 path: '/countries/canada',69 pick: (j) => {70 const d = j as { country: Record<string, unknown>; headline: Array<Record<string, unknown>> };71 const c = d.country;72 return { country: { id: c.id, name: c.name, capital: c.capital, region_name: c.region_name, income_name: c.income_name }, headline: d.headline.slice(0, 2).map((m) => ({ indicator: m.indicator, formatted: m.formatted, year: m.year, rank_world: m.rank_world, n_world: m.n_world, provenance: m.provenance })), '…': `${d.headline.length} headline metrics` };73 },74 },75 {76 key: 'series',77 path: '/countries/CAN/series/gdp-per-capita?from=2022',78 pick: (j) => {79 const d = j as { indicator: Record<string, unknown>; unit: unknown; values: Array<Record<string, unknown>>; stats: unknown; provenance: unknown };80 return { indicator: { slug: d.indicator.slug, name: d.indicator.name }, unit: d.unit, values: d.values.slice(0, 3).map((v) => ({ period: v.period, year: v.year, value: v.value, is_forecast: v.is_forecast, status: v.status })), '…': `${d.values.length} values`, stats: d.stats, provenance: d.provenance };81 },82 },83 {84 key: 'ranking',85 path: '/rankings/gdp-per-capita?limit=3',86 pick: (j) => {87 const d = j as { indicator: Record<string, unknown>; year_used: unknown; n: unknown; rows: Array<Record<string, unknown>> };88 return { indicator: d.indicator.slug, year_used: d.year_used, n: d.n, rows: d.rows.map((r) => ({ rank: r.rank, country: (r.country as Record<string, unknown>).name, formatted: r.formatted, change_1y: (r.change_1y as Record<string, unknown> | null)?.formatted ?? null })) };89 },90 },91 {92 key: 'map',93 path: '/indicators/life-expectancy/map?year=2023',94 pick: (j) => {95 const d = j as { year_used: unknown; n: unknown; legend: unknown; values: Record<string, number>; provenance: unknown };96 return { year_used: d.year_used, n: d.n, legend: d.legend, values: { CAN: d.values.CAN, JPN: d.values.JPN, NGA: d.values.NGA, '…': `${Object.keys(d.values).length} countries` }, provenance: d.provenance };97 },98 },99 {100 key: 'search',101 path: '/search?q=canada%20gdp&limit=3',102 pick: (j) => {103 const d = j as { q: unknown; n: unknown; hits: Array<Record<string, unknown>> };104 return { q: d.q, n: d.n, hits: d.hits.slice(0, 3).map((h) => ({ type: h.type, name: h.name, hint: h.hint, url: h.url })) };105 },106 },107 {108 key: 'changes',109 path: '/changes?limit=2',110 pick: (j) => {111 const d = j as { n: unknown; items: Array<Record<string, unknown>> };112 return { n: d.n, items: d.items.slice(0, 2).map((c) => ({ country: (c.country as Record<string, unknown> | null)?.name, indicator: (c.indicator as Record<string, unknown>).slug, kind: c.kind, year: c.year, severity: c.severity, headline: c.headline })) };113 },114 },115];116117async function fetchExample(ex: Example): Promise<string | null> {118 try {119 const res = await fetch(`${API_URL.replace(/\/$/, '')}/api/v1${ex.path}`, { headers: { accept: 'application/json' }, next: { revalidate: 3600 } });120 if (!res.ok) return null;121 return JSON.stringify(ex.pick(await res.json()), null, 2);122 } catch {123 return null;124 }125}126127export default async function ApiPage() {128 const bodies = await Promise.all(EXAMPLES.map(fetchExample));129 return (130 <>131 <PageHeader title={t('apiPage.title')} lede={t('apiPage.sub')} />132 <p className="max-w-prose text-base leading-relaxed text-ink-2">{t('apiPage.intro')}</p>133 <dl className="mt-5 grid gap-x-8 gap-y-3 border-y border-rule py-4 text-sm sm:grid-cols-3">134 <div>135 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.base')}</dt>136 <dd className="mt-1 break-all font-mono text-ink">{BASE}</dd>137 </div>138 <div>139 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.swagger')}</dt>140 <dd className="mt-1">141 <a href={routes.apiSwagger()} className="inline-flex min-h-[32px] items-center font-mono text-accent hover:underline">142 {routes.apiSwagger()}143 </a>144 </dd>145 </div>146 <div>147 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.openapi')}</dt>148 <dd className="mt-1">149 <a href={routes.apiOpenapi()} className="inline-flex min-h-[32px] items-center font-mono text-accent hover:underline">150 {routes.apiOpenapi()}151 </a>152 </dd>153 </div>154 </dl>155156 <Section id="endpoints" title={t('apiPage.endpoints.title')} subtitle={t('apiPage.endpoints.sub')} className="border-t-0">157 <table className="w-full border-collapse text-sm">158 <thead>159 <tr className="border-b border-rule text-left text-xs text-ink-3">160 <th scope="col" className="py-1.5 pr-3 font-medium">{t('apiPage.endpoint')}</th>161 <th scope="col" className="py-1.5 font-medium">{t('apiPage.describes')}</th>162 </tr>163 </thead>164 <tbody className="divide-y divide-rule">165 {ENDPOINTS.map(([ep, desc]) => (166 <tr key={ep}>167 <td className="py-2 pr-3 align-top">168 <code className="break-all font-mono text-xs text-ink">{ep}</code>169 </td>170 <td className="py-2 align-top text-ink-2">{desc}</td>171 </tr>172 ))}173 </tbody>174 </table>175 </Section>176177 <Section id="examples" title={t('apiPage.examples.title')} subtitle={t('apiPage.examples.sub')}>178 <div className="space-y-8">179 {EXAMPLES.map((ex, i) => (180 <div key={ex.key} className="min-w-0">181 <h3 className="mb-2 text-sm font-semibold text-ink">{t(`apiPage.ex.${ex.key}` as 'apiPage.ex.country')}</h3>182 <CodeBlock code={`curl -s "${BASE}${ex.path}"`} lang="bash" />183 <div className="mt-2 text-2xs text-ink-3">{t('apiPage.examples.live')}</div>184 {bodies[i] ? <pre className="mt-1 max-h-80 overflow-auto rounded-sm border border-rule bg-surface-2/60 p-3 font-mono text-xs leading-relaxed text-ink-2">{bodies[i]}</pre> : <p className="mt-1 text-xs text-ink-3">{t('apiPage.examples.unavailable')}</p>}185 </div>186 ))}187 </div>188 </Section>189190 <Section id="provenance" title={t('apiPage.provenance.title')} subtitle={t('apiPage.provenance.sub')}>191 <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.provenance.text')}</p>192 <CodeBlock193 className="mt-3 max-w-3xl"194 lang="json"195 code={JSON.stringify(196 {197 value: 55697.66,198 period: '2025-01-01',199 year: 2025,200 formatted: '55.7k',201 status: 'imported',202 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' },203 },204 null,205 2,206 )}207 />208 <table className="mt-4 w-full max-w-3xl border-collapse text-sm">209 <thead>210 <tr className="border-b border-rule text-left text-xs text-ink-3">211 <th scope="col" className="py-1.5 pr-3 font-medium">{t('apiPage.field')}</th>212 <th scope="col" className="py-1.5 font-medium">{t('apiPage.meaning')}</th>213 </tr>214 </thead>215 <tbody className="divide-y divide-rule">216 {PROVENANCE_FIELDS.map(([f, d]) => (217 <tr key={f}>218 <td className="py-2 pr-3 align-top font-mono text-xs text-ink">{f}</td>219 <td className="py-2 align-top text-ink-2">{d}</td>220 </tr>221 ))}222 </tbody>223 </table>224 </Section>225226 <div className="grid gap-x-10 lg:grid-cols-2">227 <Section id="limits" title={t('apiPage.limits.title')}>228 <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.limits.text')}</p>229 </Section>230 <Section id="errors" title={t('apiPage.errors.title')}>231 <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.errors.text')}</p>232 </Section>233 </div>234235 <Section id="licence" title={t('apiPage.licence.title')}>236 <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.licence.text')}</p>237 <p className="mt-2 text-sm">238 <Link href={routes.sources()} className="text-accent hover:underline">239 {t('sources.title')} →240 </Link>241 <span className="mx-2 text-ink-3">·</span>242 <Link href={routes.methodology()} className="text-accent hover:underline">243 {t('method.title')} →244 </Link>245 </p>246 <div className="mt-6 text-sm text-ink-2">247 <span className="mr-2 font-medium text-ink">{t('apiPage.contact')}</span>248 <FooterCredits className="mt-1 inline text-xs" />249 </div>250 </Section>251 </>252 );253}254