mibudge

API and models

How the SPA talks to the REST API (src/api/) and how server data becomes the objects the UI works with (src/models/). See architecture.md for where these layers sit.


The transport: api/http.ts

createHttpClient(config) returns an HttpClient. It is the only place the SPA calls fetch; the architecture test enforces that.

interface HttpClient {
  request<T>(path: string, options?: RequestOptions): Promise<T>;
  get<T>(path: string, query?: object): Promise<T>;
  post<T>(path: string, json?: unknown): Promise<T>;
  patch<T>(path: string, json: unknown): Promise<T>;
  delete<T = null>(path: string): Promise<T>;
  refresh(): Promise<boolean>; // single-flight token refresh
}

interface RequestOptions {
  method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  json?: unknown;     // JSON body, sets Content-Type
  form?: FormData;    // multipart body; the browser sets the boundary
  query?: object;     // ?a=1&b=true; undefined / null / "" are dropped
  auth?: boolean;     // false: no Authorization header, no 401 refresh
  signal?: AbortSignal;
}

What the client does on every request:

The config callbacks (getToken, refresh, onAuthFailure) come from the session store through createSessionHttpClient() in stores/session.ts, so api/ imports nothing from Vue or Pinia.

toQueryString(params) and pathOf(url) are exported helpers. pathOf turns DRF’s absolute next links into a path, so paging stays on baseUrl.

The api registry

api/index.ts exports one object with a namespace per resource:

import { api } from "@/api";

const page = await api.budgets.list({ bank_account: accountId, archived: false });
const all = await api.pages.all(page);        // follow every `next` link
await api.transactions.update(id, { memo: null });

initApi(http) binds the resource modules to a client. main.ts calls it once and tests/setup.ts calls it before each test. Only stores and feature composables import api.


Errors: api/errors.ts

class ApiError extends Error {
  status: number;
  body: string;                             // raw response text
  detail: string | null;                    // DRF {"detail": "..."}
  fieldErrors: Record<string, string[]>;    // DRF {"field": ["..."]}, nested keys as "a.b"
  nonFieldErrors: string[];                 // non_field_errors, or a top-level list
  serverMessage: string | null;             // detail ?? nonFieldErrors[0] ?? first field error
  // message: serverMessage ?? `HTTP <status>`
}

class AuthError extends Error {}            // refresh failed; the session is over

class NetworkError extends Error {}         // fetch rejected, no response; `cause` is the rejection

Helpers:

In a form, useFormErrors().setError(err, { fallback, statusMessages, inlineFields }) puts the message for each field listed in inlineFields next to its input, and the rest (non-field errors, and messages for fields the form does not show) into a form-level message. A form that shows no per-field messages omits inlineFields.

Reporting failures

Every failed page load or user action shows a message, and the message comes from describeError(err, fallback) (or useFormErrors), so the user sees the server’s reason whenever it gave one. The fallback names what failed (“Failed to archive budget.”). Don’t replace the server’s message with fixed text, and don’t catch an error only to rethrow a plain Error: that throws away the status and the message.

useAsync, useResource, useInfiniteList and useDebouncedAutosave already apply this: pass errorMessage as the fallback and render their error.

A bare catch {} or .catch(() => undefined) is right only for work the user did not ask for and can do without. Examples: refetching balances after an action that succeeded, the top bar’s Unallocated balance, or a cosmetic lookup like a bank’s name. Say so in a comment next to it.


Generated types: api/schema.d.ts

api/schema.d.ts is generated from docs/openapi.yaml by openapi-typescript. Never edit it by hand. It is excluded from formatting (frontend/.prettierignore).

Regenerate it whenever the REST API changes:

make api-schema                       # repo root: refresh docs/openapi.yaml
cd frontend && pnpm gen:api-types     # regenerate src/api/schema.d.ts
pnpm type-check                       # see what the change broke

pnpm gen:api-types runs openapi-typescript. It then runs scripts/stamp-api-types-header.mjs, which prepends the “generated file” header.

The Drone frontend lint step runs pnpm gen:api-types && git diff --exit-code src/api/schema.d.ts. Committing an API change without regenerating the types fails CI.

DTO aliases: api/dto.ts

Code never indexes components["schemas"] directly. api/dto.ts names each schema type:

Kind Name Example
Response body <Thing>Dto BudgetDto = Schemas["Budget"]
Create body <Thing>CreateDto BudgetCreateDto = Schemas["BudgetRequest"]
Partial update body <Thing>UpdateDto BudgetUpdateDto = Schemas["PatchedBudgetRequest"]
List query parameters <Thing>ListQuery operations["budgets_list"]["parameters"]["query"]
Enum <Enum>Dto BudgetTypeDto = Schemas["BudgetTypeEnum"]

api/dto.ts hand-writes a type only where the schema cannot express the response, and says why next to it. For example, Page<T> exists because the schema marks next / previous optional though the server always sends them.


DTO vs model

A DTO is exactly what goes over the wire. It has snake_case keys, money as a decimal string plus a sibling *_currency, dates as strings, and foreign keys as bare ids. Only api/resources/ and models/ use DTOs.

A model is what the rest of the SPA uses. It has camelCase keys, Money for amounts, LocalDate for calendar dates, ...Id suffixes on foreign keys, and null rather than "" or a missing key.

Each file in models/ has:

models/page.ts has ModelPage<T> and pageFromDto(page, fromDto) for lists the UI pages through (useInfiniteList).

Mappers never throw on odd server data. They fall back to a safe value (budgetType ?? "G", toLocalDate("") → null) and say so in a comment.


Money rules

Date rules

The API sends two kinds of date, and domain/dates.ts treats them differently:


Adding a REST resource, end to end

This walks through a worked example: letting the SPA create transaction categories (POST /api/v1/transaction-categories/). The server already supports it; the SPA only lists and reads them today.

  1. Server and schema. Make the backend change, then run make api-schema at the repo root so docs/openapi.yaml is current.
  2. Types. Run cd frontend && pnpm gen:api-types, then name the new schema types in src/api/dto.ts:

    export type TransactionCategoryCreateDto = Schemas["TransactionCategoryRequest"];
    
  3. Resource function. Add the call to its module in src/api/resources/, or create a module for a new resource:

    // src/api/resources/transactionCategories.ts
    create(body: TransactionCategoryCreateDto): Promise<TransactionCategoryDto> {
      return http.post(`${V1}/transaction-categories/`, body);
    },
    

    A new module exports <resource>Resource(http) and is added to createApi in src/api/index.ts.

  4. Model. In src/models/transactionCategory.ts, add an input type and a mapper for the request body. Response mapping reuses transactionCategoryFromDto:

    export interface TransactionCategoryInput { group: string; name: string }
    
    export function transactionCategoryToCreateDto(
      input: TransactionCategoryInput,
    ): TransactionCategoryCreateDto {
      return { group: input.group, name: input.name };
    }
    
  5. Caller. Decide who calls the endpoint (see state.md). Data shared across pages goes in a store action that updates the cache. Data for one section goes in a feature composable. Either way, the caller maps the result with *FromDto before storing or returning it.
  6. Tests:
    • tests/mocks/handlers.ts: a default handler for the endpoint, shaped per docs/openapi.yaml.
    • tests/mocks/factories.ts: a make* factory if the resource is new.
    • tests/api/resources.test.ts: one row with the call, method, path and body.
    • tests/models/: a table for the mapper, covering nulls and defaults.
    • A test for the store action or feature composable that calls it.
  7. Checks. Run pnpm type-check && pnpm test:coverage. The api/ and models/ coverage thresholds are 80% lines.