Skip to content

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:

  1. 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']
    >
  2. Calls generatedClient and resolves with unwrap (both from @/lib/apiClientGenerated). generatedClient is the typed openapi-fetch client; unwrap returns the typed payload or throws an ApiError (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 call encodeURIComponent yourself. The query serializer sends every non-undefined value, including empty strings.

  3. Deletes any hand-written src/types/<domain>.ts for wire shapes. Import the domain’s types from its service instead.

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 is SuccessResponse & { data: X } (or a named *Envelope schema). 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.

  • Multipart upload: pass the typed body (the spec types the binary part as string, so pass the File with a narrow cast) plus a bodySerializer that returns FormData. openapi-fetch then drops its JSON Content-Type so the browser sets the multipart boundary. See attachmentService.upload.
  • Blob download: stays a thin documented fetch wrapper — unwrap resolves JSON payloads only. See attachmentService.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: generatedClient combines the caller’s AbortSignal with a 30s timeout via AbortSignal.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 the ApiError class + APIErrorResponse in errors.ts. These have no wire surface.
  • version.ts re-exports the generated ContentVersion and 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 of openapi.yaml on purpose (documenting them would break the spec drift + payload-coverage gates — see #89 / #34). oauthService therefore uses a tiny, self-contained local fetch wrapper that still throws ApiError. This is the one sanctioned exception; do not grow it into a second general client.

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 removed ApiResponse<T> envelope

If you hit it, you’re reaching for retired plumbing — use generatedClient + unwrap and re-export wire types from your service instead.