frontend-api · git:20260916.b334104 · 2026-09-16 · sha256 2b6f59402374903b

frontend-api git:20260916.b334104A

Immutable. This exact content is served forever at /api/v1/blob/2b6f59402374903b.

---
name: frontend-api
description: Frontend API integration — generated SDK, apiCallWithLoading, blockUi, wrapped responses, toOpenApiError. Use when calling the generated backend API from the frontend.
paths:
  - "frontend/src/**/*.vue"
  - "frontend/src/**/*.ts"
---
# Frontend API Rules

## API Integration

- Import services from `@generated/donut-backend-api/sdk.gen`.
- Wrapped response: `{ data, error, request, response }`.
- User-initiated actions: `apiCallWithLoading` (loading bar + error toasts).
- Direct SDK calls are silent: no loading bar and no error toasts.

```typescript
import { UserController } from "@generated/donut-backend-api/sdk.gen"
import { apiCallWithLoading } from "@/managedApi/clientSetup"

const { data: newUser, error } = await apiCallWithLoading(() =>
  UserController.createUser({ body: formData })
)

const { data: users, error: loadError } = await UserController.getUserProfile()
```

## Loading indicators

`apiCallWithLoading` drives two global surfaces from `DonutApp.vue`:

- **Thin loading bar** — any wrapped call in flight.
- **Whole-UI blocking modal** — `{ blockUi: true }`. Native `<dialog showModal()>`, so it paints above other top-layer dialogs.

Use `{ blockUi: true, message?: string }` when the rest of the UI must wait (view transition, or a mutation where partial interaction would confuse state). Show the blocker only after any confirmation. Do **not** add component-local `LoadingModal` refs.

```typescript
const { data, error } = await apiCallWithLoading(
  () => AssimilationController.next({ query: { timezone: timezoneParam() } }),
  { blockUi: true, message: "Loading next note..." }
)
```

Keep local loading (inline spinner, disabled control) when the user should still use the rest of the page. Those calls can still use `apiCallWithLoading` without `blockUi`.

### Cancelable blocking calls

For safe **read-only** blockers, pass `{ blockUi: true, cancelable: true, message?: string }`. Returns `CancelableApiResult<T>` (`{ status: "completed"; result }` | `{ status: "cancelled" }`). Narrow on `status` before using `result`; do not treat cancel as an API error.

```typescript
const outcome = await apiCallWithLoading(
  (signal) =>
    AiController.generateRefinementSuggestions({
      path: { note: noteId },
      signal,
    }),
  {
    blockUi: true,
    cancelable: true,
    message: "AI is generating refinement layout...",
  }
)

if (outcome.status === "cancelled") {
  return
}

const { data, error } = outcome.result
```

Accepted cancel is silent (no toast). Abort is **client-only**. Do **not** opt mutations or irreversible writes into `cancelable: true`. Do not invent AbortError-name matching or a cancelable `runWithBlockingApiLoading`.

`cancelable: true` is allowlisted in `frontend/tests/managedApi/cancelableAllowlist.spec.ts` (`ALLOWED_CANCELABLE_FILES`). Add a site there when introducing a new cancelable call; do not add `cancelable: true` outside that set.

For one continuous blocker across multiple calls, wrap them in `runWithBlockingApiLoading(operation, message)` (noncancelable). Inner `apiCallWithLoading` calls keep thin bar + toasts only.

### Blocking classification

Every new `{ blockUi: true }` or `runWithBlockingApiLoading` site is **cancelable**, **intentionally noncancelable**, or **nonblocking**. Classify it; do not leave it unclassified.

- **Cancelable** — only allowlisted read-only opt-ins (`cancelableAllowlist.spec.ts`).
- **Intentionally noncancelable** — mutations, view transitions that must finish, AI reads with no defined post-cancel UX.
- **Nonblocking** — thin-bar `apiCallWithLoading` without `blockUi`, or a direct SDK call.

Loading UI that means unfinished work marks `data-app-busy` (`LoadingThinBar`, `ContentLoader`, `LoadingModal`; gated by `frontend/tests/components/commons/AppBusyMarker.contract.spec.ts`). E2E: `waitUntilAppIsNotBusy()` from `e2e_test/start/pageBase.ts`. The wait means busy UI cleared, not success.

## API Return Value Usage

The global client uses `responseStyle: "fields"` and `throwOnError: false`.

1. Destructure with meaningful names: `const { data: updatedUser, error } = await updateUser(...)`.
2. Check `!error` before using `data`; no separate `data` check.
3. When `error` is undefined, TypeScript types `data` as the success shape.
4. Do not add runtime property checks for required typed properties.
5. Do not add `else if (data)` after checking `error`.

## Validation Errors

For 400 field errors, `toOpenApiError(error)` (`{ errors?: Record<string, string>; message?: string }`). `apiCallWithLoading` already toasts; extract `errors` only for form fields.

```typescript
import { toOpenApiError } from "@/managedApi/openApiError"

if (error) {
  errors.value = toOpenApiError(error).errors || {}
} else {
  errors.value = {}
  user.value = updatedUser
}
```