Skip to main content

Typed data-fetching — @tedos/api-client (the one seam every Next app reads through)

canonical · frontend · updated 2026-06-27 · source

Every server read in a TED OS Next app goes through one typed transport, generated from the backend contract — never a per-app hand-rolled fetcher. This is the realization of §BD (DF1–DF6) in .claude/rules/react-style-guidelines.md, built on the thin-app architecture (A1–A5) ratified by ADR-008. Efforts #724 / #738.

TL;DR

  • @tedos/api-client (packages/api-client/src/index.ts) is the ONLY place HTTP + auth header + caching live. It wraps openapi-fetch, typed by the generated @tedos/api-types.
  • @tedos/api-types (packages/api-types/src/index.ts) is generated by openapi-typescript from apps/api/openapi.json. Regenerate with pnpm gen:api. The committed spec + types ARE the drift check.
  • Each app owns a ~10-line lib/api-client.ts seam injecting only baseUrl + a Clerk getToken. Everything else is in the package.
  • Reads return a discriminated ApiResult<T> (toResult), never a throw or a swallowed fallback — so the page can render the true error-vs-empty states.
  • Tenant data is no-store (default in the package); cross-request dedupe is React.cache(), not HTTP caching.
  • Offline fixtures are returned behind one flag (apiConfigured) per resource.

Why this exists

Before #724 each app shipped its own engineFetch/engineGet: admin's lib/e1-api.ts and a verbatim copy as comprender's lib/api.ts. Two transports drifted, neither was typed against the contract, and several reads silently collapsed a failure into a zeroed fallback — so the page could not tell "empty" from "the API is down". Both twins are now deleted (verified absent); the transport lives once in the package (ADR-008 A2: reusable/presentational → a package), and the apps keep only their auth wiring (A1/A4: auth stays app-owned).

The one transport — @tedos/api-client

packages/api-client/src/index.ts exports:

ExportWhat it is
createApiClient(config)builds a typed Client<paths> (openapi-fetch). Attaches a bearer Middleware (onRequest sets Authorization: Bearer <token> from getToken) and defaults every request to cache: 'no-store'.
isApiConfigured(baseUrl)true when a non-empty base URL is set — the one offline flag.
toResult(outcome)collapses an openapi-fetch outcome into an ApiResult. Never throws.
ApiResult<T>{ ok: true; data: T } | { ok: false; error: ApiError }
ApiError{ status; message; body? }; status: 0 = unconfigured base URL or a network/transport error.
ApiClient / ApiClientConfig / FetchOutcome<T>the typed client alias + the seam config + the openapi-fetch result shape.

Dependencies (packages/api-client/package.json): openapi-fetch + @tedos/api-types. The package is framework-agnostic — no Clerk, no next/* import. It is RSC-safe by construction: you construct + call it server-side only (the token getter reads the Clerk session).

toResult semantics (DF4):

  • error !== undefined (transport or non-2xx) → { ok: false, error }
  • data === undefined (2xx, empty body) → { ok: false, error: 'API returned an empty response body' }
  • otherwise → { ok: true, data } — the page derives empty-vs-success from the data itself.

Generated types — @tedos/api-types

@tedos/api-types is not hand-written. packages/api-types/src/index.ts carries the This file was auto-generated by openapi-typescript. Do not make direct changes header and exports the paths map the client is typed against. The pipeline:

zod route schemas (@tedos/shared/api) ← the ONE hand-authored source of truth
│ pnpm -F @tedos/api gen:openapi (apps/api/src/scripts/gen-openapi.ts)

apps/api/openapi.json ← committed spec (#721); offline Fastify+swagger dump, no DB/Clerk
│ pnpm -F @tedos/api-types gen (openapi-typescript)

@tedos/api-types → @tedos/api-client → every app's lib/api-client.ts

Both steps run together via the root pnpm gen:api script (package.json). Run it after any route/contract change; the committed spec + types ARE the drift check — regenerate → no diff (DF2). The FE does not re-validate spec-typed responses; zod stays the server-side SoT.

gen-openapi.ts boots the Fastify app via build() without listening, asks @fastify/swagger for the spec, and writes apps/api/openapi.json — deterministic, no running server, CI-safe (must run with NODE_ENV != production, since swagger//docs are dev-only).

The thin per-app seam

Each app owns a ~10-line lib/api-client.ts that supplies only the app-specific bits — base URL + the Clerk getToken:

  • admin (apps/admin/src/lib/api-client.ts): BASE = process.env.ENGINE_API_URL; exports apiConfigured + api. projectId is resolved from the route param and passed into each data-fn (e.g. getSummary(projectId)).
  • comprender (apps/clients/comprender/src/lib/api-client.ts): same shape, BASE defaults to https://api.tuempresa.digital; also exports apiBaseUrl (shared with the tenant-config seam). projectId is the Host-resolved tenant id (tenant-config.ts), per comprender's runtime multi-tenant MVP.

Both inject getToken: async () => (await auth()).getToken() (Clerk, A1/A4). projectId is always the app's to resolve — never hardcoded in the package (DF3).

Data-fns — the Result contract in practice

A data-fn wraps one endpoint, returns ApiResult<T>, and branches on the offline flag. Naming differs by app (admin: lib/*-fixtures.ts; comprender: lib/*-data.ts) but the shape is identical.

Example — comprender getSummary() (apps/clients/comprender/src/lib/summary-data.ts):

export async function getSummary(): Promise<ApiResult<GetSummaryResponse>> {
if (!apiConfigured) return { ok: true, data: FIXTURE_SUMMARY } // DF6: one fixture per resource
const { projectId } = await getTenantConfig()
const out = await api.GET('/p/{id}/summary', { params: { path: { id: projectId } } })
return toResult(out) // DF4: Result, never a throw/fallback
}

The page consumes it and reaches the 4 states (the rule file, H1) — e.g. apps/clients/comprender/src/app/(shell)/summary/page.tsx does const result = await getSummary(); if (!result.ok) { …state="error"… } and otherwise derives empty-vs-success from the data. Admin mirrors this in summary-fixtures.ts (getSummary(projectId)) and students-fixtures.ts (getStudents / getStudent over GET /p/{id}/students and GET /p/{id}/students/{sid}).

Caching — no-store + React.cache

  • Tenant data is no-store (DF5): the package sets cache: 'no-store' on the client, so a read is never cached across requests/tenants.
  • Cross-request dedupe is React.cache(), not HTTP caching. comprender's getTenantConfig (apps/clients/comprender/src/lib/tenant-config.ts) is wrapped in cache(…) so the public GET /tenant/config is fetched at most once per request and shared across the whole server tree.

Offline fixtures — one flag

When the API is unwired (apiConfigured false — local dev / preview without infra) a data-fn returns a typed fixture as ok (DF6): one flag, one fixture per resource, never a second fetch path. comprender additionally falls back to a fixed OFFLINE_TENANT config when the Host is local / unresolvable, so the app always renders the populated example.

Boundaries

  • The transport reaches the product API (ENGINE_API_URL) — distinct from the dev-workflow Plan-Engine sync, which is a server-side concern in apps/api and reads GH_PAT.
  • Auth never lives in the package — apps inject getToken; the API fails closed (401) when there is no session, and the read surfaces an error (status from the response, or 0 for transport failures).