SPB Git

spb/countryatlas

Public
12.4 KB · 254 lines tsx
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
Files · d588bee37a233436d1bef2f0970b4b557e87e5f7
Menu

Theme