Frontend API Integration
How the SPA talks to the backend. The OpenAPI spec is the single source of
truth. The backend
openapi.yaml
is published as the generated @vibexp/api-client npm package; the frontend
consumes its types and a typed openapi-fetch client. There is no hand-written
HTTP client and no hand-written wire types anymore (retired in #94, epic #87).
If you are adding or changing an API call, follow the pattern below. A
no-restricted-imports ESLint rule blocks the old plumbing, so the compiler and
linter will point you here.
For how the spec itself is authored and generated, see API & OpenAPI.
The pattern (a.k.a. the notificationService pattern)
Section titled “The pattern (a.k.a. the notificationService pattern)”Each domain gets one service module under
src/services/.
It does three things:
-
Re-exports the wire types from the generated schema so components import them from the service, not from a hand-written type file:
import type { components, operations } from '@vibexp/api-client'export type Prompt = components['schemas']['Prompt']export type PromptListResponse = components['schemas']['PromptListResponse']// Query-param bags come off `operations`, wrapped in NonNullable:export type ListPromptsQuery = NonNullable<operations['listPrompts']['parameters']['query']> -
Calls
generatedClientand resolves withunwrap(both from@/lib/apiClientGenerated).generatedClientis the typedopenapi-fetchclient;unwrapreturns the typed payload or throws anApiError(the same error type the whole app already handles — RFC 9457 problem details, with.status/.code).import { generatedClient, unwrap } from '@/lib/apiClientGenerated'async getPrompt(teamId: string, slug: string): Promise<Prompt> {return unwrap(generatedClient.GET('/api/v1/{team_id}/prompts/{slug}', {params: { path: { team_id: teamId, slug } },}))}Paths are the full spec paths (they include
/api/v1/...). Path params are percent-encoded by openapi-fetch — do not callencodeURIComponentyourself. The query serializer sends every non-undefinedvalue, including empty strings. -
Deletes any hand-written
src/types/<domain>.tsfor wire shapes. Import the domain’s types from its service instead.
The envelope rule (important)
Section titled “The envelope rule (important)”The backend’s writeJSON writes the value raw — it does not auto-wrap. So
whether a response is enveloped depends on the handler:
- Raw payload (
writeOK(w, obj)) → the generated response type is the object.unwrap(...)returns it directly. - Enveloped list/paginated (
writeOK(w, {status, message, data})) → the generated type isSuccessResponse & { data: X }(or a named*Envelopeschema). Do(await unwrap(...)).data.
Check the operation’s responses.200.content['application/json'] in the
generated schema.d.ts to know which. Don’t assume.
Recipes
Section titled “Recipes”- Multipart upload: pass the typed body (the spec types the binary part as
string, so pass theFilewith a narrow cast) plus abodySerializerthat returnsFormData. openapi-fetch then drops its JSONContent-Typeso the browser sets the multipart boundary. SeeattachmentService.upload. - Blob download: stays a thin documented
fetchwrapper —unwrapresolves JSON payloads only. SeeattachmentService.download. - Local aliases to avoid churn: when a generated schema rename would touch
dozens of call sites, alias it locally in the service
(
export type TeamMember = components['schemas']['TeamMemberDetail']) rather than editing every importer. - Cancellation + timeout:
generatedClientcombines the caller’sAbortSignalwith a 30s timeout viaAbortSignal.any, so passing{ signal }still cancels in-flight requests.
What is NOT generated (intentional exceptions)
Section titled “What is NOT generated (intentional exceptions)”- UI-only types stay hand-written under
src/types/, imported by direct path:alert.ts,analytics.ts,help.ts,a2a.ts, and theApiErrorclass +APIErrorResponseinerrors.ts. These have no wire surface. version.tsre-exports the generatedContentVersionand keeps a few resource-agnostic aliases; import it directly (@/types/version).- The OAuth consent surface (
oauthService+types/oauth.ts) is deliberately hand-written. Its endpoints (/oauth/consent[/attach]) are served by the embedded Authorization Server and are kept out ofopenapi.yamlon purpose (documenting them would break the spec drift + payload-coverage gates — see #89 / #34).oauthServicetherefore uses a tiny, self-contained localfetchwrapper that still throwsApiError. This is the one sanctioned exception; do not grow it into a second general client.
Guardrail
Section titled “Guardrail”eslint.config.js
has a no-restricted-imports rule that blocks:
@/lib/apiClient/**/lib/apiClient— the removed hand-written client@/types— the removed types barrel (import UI-only types by direct path)@/types/api/**/types/api— the removedApiResponse<T>envelope
If you hit it, you’re reaching for retired plumbing — use generatedClient +
unwrap and re-export wire types from your service instead.