Client Generation
Ontogen generates TypeScript client code that mirrors your server API surface. Three client generators cover different deployment scenarios: a pure HTTP client, a split transport that switches between HTTP and Tauri IPC at runtime, and an admin registry that provides per-entity metadata for admin UIs.
Client generator variants
Section titled “Client generator variants”Client generators are configured through the generators field on ClientsConfig and run by gen_clients (or, more commonly, Pipeline::clients(...).build()):
use ontogen::clients::ClientGenerator;use ontogen::ClientsConfig;
let clients_config = ClientsConfig { // ... api_dir, state_type, naming, etc. generators: vec![ ClientGenerator::HttpTs { output: "../src-nuxt/app/types/httpCommands.ts".into(), bindings_path: "../src-nuxt/app/types/bindings.ts".into(), }, ClientGenerator::HttpTauriIpcSplit { output: "../src-nuxt/app/transport/generated.ts".into(), bindings_path: "../src-nuxt/app/types/bindings.ts".into(), }, ClientGenerator::AdminRegistry { output: "../src-nuxt/layers/admin/generated/admin-registry.ts".into(), }, ], // ... extra_surfaces: vec![],};ClientGenerator is at ontogen::clients::ClientGenerator.
Formatting the output
Section titled “Formatting the output”Ontogen does not format the TypeScript it generates. ClientsConfig::ts_formatter defaults to TsFormatter::None, which writes each file exactly as the generators produced it.
That’s usually what you want when the consuming project runs its own formatter over the tree. If you’d rather the generated files land already matching your style:
// Shell out to an external formatter. The output file's resolved path is// appended as the final argument, so prettier can resolve its own config.// Runs from the nearest ancestor node_modules directory when one exists.ts_formatter: ontogen::TsFormatter::Command(vec![ "prettier".into(), "--stdin-filepath".into(),]),
// Or format in-process with a library of your choice.ts_formatter: ontogen::TsFormatter::custom(|src, _path| { my_formatter::format(src).map_err(|e| e.to_string())}),TsFormatter::custom fails the build if the hook returns Err. TsFormatter::custom_with(f, OnFormatError::…) lets you pick a laxer policy for cases where formatting is nice-to-have but shouldn’t be able to break codegen.
TypeScript HTTP client
Section titled “TypeScript HTTP client”The HttpTs generator produces a typed, fetch-based HTTP client. For each API function, it generates a TypeScript method that makes the appropriate HTTP request.
The server speaks JSON:API, and the client hides that. The methods have flat signatures: entities go in and come out as plain objects with the schema’s field names, null for cleared optionals and id arrays for relations. The client turns each resource object into a flat entity (flattenTask: the id under the entity’s own id field name, then attributes, then relationships as epic_id or a tags id array) and turns flat inputs back into { data: { type, id?, attributes, relationships } } request bodies (unflattenTask). Each entity’s resource type, id field and relationships come from a generated per-entity resource table. The normative mapping is §14 of the JSON:API wire contract.
The generated file exports one httpCommands object:
// Auto-generated HTTP client. DO NOT EDIT.// Generated by ontogen from API source files. DOM and JavaScript globals// are written `globalThis.X`, since your types may share their names// (`Response`, `Map`, `Event`).
import type { CreateTaskInput, Task, UpdateTaskInput,} from './bindings';
export const httpCommands = { async taskList(options?: ListOptions<TaskSortKey>): Promise<Task[]> { const { data } = await httpGet<JsonApiCollectionDocument>(`/tasks${toQueryString({ sort: options?.sort })}`); return data.map(flattenTask); },
async taskGetById(id: string): Promise<Task> { const { data } = await httpGet<JsonApiResourceDocument>(`/tasks/${encodeURIComponent(id)}`); return flattenTask(data); },
async taskCreate(input: CreateTaskInput): Promise<Task> { const { data } = await httpPost<JsonApiResourceDocument>('/tasks', unflattenTask(input)); return flattenTask(data); },
async taskUpdate(id: string, input: UpdateTaskInput): Promise<Task> { // PATCH /api/tasks/{id} with unflattenTask(input, id), then flattenTask(data) },
async taskDelete(id: string): Promise<null> { // DELETE /api/tasks/{id}; resolves null on 204 },};Method names follow the same entity-first convention as the server transports: taskList, taskGetById, taskCreate. The naming is camelCase for TypeScript conventions. HttpTs has no event subscriptions; those are on the split transport.
On the wire:
- Every request sends
Accept: application/vnd.api+json. Requests with a body sendContent-Type: application/vnd.api+json. - Updates use
PATCH(httpPatch). No generated route usesPUT, so there is nohttpPuthelper. - A key whose value is
undefinedis left out of an update body, which means “unchanged”.nullis sent and clears the field, except on a to-many relation, wherenullis left out too. A relation field moves intorelationships; every other key goes inattributes. The id goes indata.id, never inattributes; an empty create id is left out, so the server derives one. - A paginated list takes
limit?andoffset?, sends them aspage[offset]andpage[limit], and returnsPaginatedResult<T>(items,total,limit,offset), built fromdataandmeta.limitandoffsetare the effective values the server used, after clamping. This holds for theHttpTauriIpcSplitHTTP transport and forHttpTs. A paginated module has noxCount()method: no route or command serves itscount, because the page carriestotal. includeis not exposed on the TS clients. It is an HTTP-only query parameter, and the flat return shape (entities,PaginatedResult<T>) has nowhere to putincludedresources; call the route directly to use it.- A filtered list sends the
filter[...]family:toQueryString({ filter: query, page: { offset, limit } })for a query struct,{ skill_id: skillId }for bare parameters, and{ ...query, skill_id: skillId }for both. A filtered list with no entity behind it sendsfilter[...]withopArg[limit]andopArg[offset]. The method takes bare parameters, thenquery?, thenlimit?andoffset?.
Sorting lists
Section titled “Sorting lists”A list whose Rust fn takes an order (every generated list, and a hand-written one that opts in) takes a trailing options parameter, after every other parameter, projectId included:
export interface ListOptions<K extends string> { sort?: K[];}export type TaskSortKey = 'id' | '-id' | 'title' | '-title' | 'status' | '-status' | 'created' | '-created';
taskList(query?: ListTasksQuery, limit?: number, offset?: number, options?: ListOptions<TaskSortKey>): Promise<PaginatedResult<Task>>;
await transport.taskList(undefined, 20, 0, { sort: ['-created', 'title'] });TaskSortKey lists the entity’s sort fields, ascending then descending for each, in field order. ListOptions and each {Entity}SortKey are exported beside PaginatedResult from every generated client file, and only when some emitted list sorts. A list that takes no order has no options. Without options the list’s default order applies, which for a generated list is id ascending.
The HTTP transports send sort=-created,title, between the filter and the page, and send nothing for an absent or empty array. The IPC transport passes sort in the invoke arguments. The query-string helper writes an array value as one parameter with its items joined by ,, and skips an empty array. Over HTTP a filter travels as filter[...], so it cannot meet sort; over IPC a bare filter named sort would share the invoke key, so the build refuses one on a sorted list when it emits the IPC server or the split transport.
Errors
Section titled “Errors”Any non-2xx response, from a CRUD route or any other generated route, throws a JsonApiError (the file also exports JsonApiErrorObject and JsonApiResource):
export class JsonApiError extends globalThis.Error { override readonly name = 'JsonApiError'; readonly status: number; readonly errors: JsonApiErrorObject[]; // [] when the body was not a JSON:API error document}message is the first error’s detail (or its title, or the status text), so String(e) reads JsonApiError: Task not found: nope. Read e.status and e.errors[0]?.code when you need to branch on the failure; code is stable (task_not_found, invalid_sort_field, …) and detail is not. A non-JSON body, such as a proxy’s HTML 502, also throws a JsonApiError, with an empty errors. The codes are listed under Errors. The IPC transport rejects with the command’s error string instead.
Custom ops and junction methods
Section titled “Custom ops and junction methods”Custom endpoints discovered through API scanning get client functions too. They follow the custom op wire: a POST sends its arguments as { meta: { args: { … } } } (no body when there are none), a GET sends Option arguments as opArg[name], and every call resolves to the response’s meta.result (null on 204). The same holds for the junction and CRUD methods of a module with no entity behind it, and for a lone list_X (one with no add_Y or remove_Y beside it), which is a custom GET and returns its plain array. Entity-less lists and the junction lists of such a module page with opArg[limit] and opArg[offset] and return PaginatedResult<T>, which the server sends as meta.result.
async taskGetOverdue(): Promise<Task[]> { return callOp<Task[]>('GET', '/tasks/overdue');},Junction methods of a module named after an entity call its relationship endpoints, with the same signatures: taskListLabels(taskId) reads GET /api/tasks/{taskId}/labels and flattens each resource (a list of ids reads …/relationships/labels and maps each identifier to its id), and on a paginated surface takes limit? and offset?, sends page[…] and returns PaginatedResult<T>. taskAddLabel(taskId, tagId) and taskRemoveLabel(taskId, tagId) send POST and DELETE /api/tasks/{taskId}/relationships/labels with { data: [{ type: 'tags', id: tagId }] } and resolve null. With a route prefix, the split transport calls the same paths under the prefix. Subscriptions hand the callback a flat entity for an entity item and the meta.result value for any other item. HttpTs calls the server’s own paths, kebab-cased and singleton-aware.
The bindings_path option
Section titled “The bindings_path option”The bindings_path points to the TypeScript file that holds the type definitions the client imports. Ontogen owns this file — on every build it rewrites the path with a fresh set of TS aliases. Two emitters cooperate to populate it:
- Schema-known emitter — entity types from
src/schema/plus theCreate{Entity}Input/Update{Entity}InputDTOs Ontogen derives are emitted directly fromEntityDefvia a bounded mapping overFieldType. No user setup required. - Long-tail emitter — user-owned types referenced by a custom API endpoint’s input or return type (anything that isn’t an entity or a generated DTO) are emitted by the
ontogen-tscrate. A build-time AST walker scans yoursrc/for the reachable closure of every long-tail type and appends the result tobindings_path.
The long-tail emitter runs entirely inside build.rs. There is no separate compilation, no extra binary, no cargo invocation, no CARGO_TARGET_DIR isolation. The walker reads .rs files with syn and emits TypeScript directly from the AST.
You don’t need to opt long-tail types in — the walker discovers them automatically from your API signatures and recursively pulls in everything they reference. If a referenced type lives in a workspace-sibling crate, declare the sibling’s source root in pool_extra_roots; see the TypeScript bindings guide.
If a type’s shape isn’t in the supported subset, or a serde attribute isn’t recognized, or a reference can’t be resolved, the build hard-errors with the full punch-list. See the error model for the four variants and how to handle each.
For the full ontogen-ts story — supported subset, serde rename family, BigInt rendering, external types, the #[ontogen::ts_opaque] / #[ontogen::ts_name] escape hatches — see the dedicated TypeScript bindings guide.
Split transport (HTTP + Tauri IPC)
Section titled “Split transport (HTTP + Tauri IPC)”The HttpTauriIpcSplit generator is for applications that run as both a web app and a Tauri desktop app. It generates a Transport interface with two implementations:
export interface Transport { taskList(options?: ListOptions<TaskSortKey>): Promise<Task[]>; taskGetById(id: string): Promise<Task>; taskCreate(input: CreateTaskInput): Promise<Task>; // ... all operations}
export function createHttpTransport(): Transport { return { async taskList(options?: ListOptions<TaskSortKey>): Promise<Task[]> { const { data } = await httpGet<JsonApiCollectionDocument>(`/tasks${toQueryString({ sort: options?.sort })}`); return data.map(flattenTask); }, // ... };}
export function createIpcTransport(): Transport { return { async taskList(options?: ListOptions<TaskSortKey>): Promise<Task[]> { return invoke('task_list', { sort: options?.sort }); }, async taskGetById(id: string): Promise<Task> { return invoke('task_get_by_id', { id }); }, // ... };}At runtime, you detect the environment and pick the right transport:
const transport = window.__TAURI__ ? createIpcTransport() : createHttpTransport();Both implementations have identical signatures and return the same flat values. JSON:API applies to the HTTP transport only: IPC invokes the commands with flat arguments and gets flat entities back. Code that uses the transport doesn’t know or care whether it’s talking to a local server over HTTP or to the Tauri backend over IPC.
Event subscriptions
Section titled “Event subscriptions”Each event op gets a subscribeX(args, handlers) method on the Transport interface and both implementations:
subscribeVaultNoteChanges( args: { vaultId: string; classes?: string | null; resume?: string | null }, handlers: SubscriptionHandlers<LoggedChange>,): Promise<() => void>; // resolves to the unsubscribe function
interface SubscriptionHandlers<T> { onEvent: (data: T, id: string | null) => void; onLag?: (skipped: number) => void; onOpen?: () => void; // first connect and every reconnect onError?: (err: unknown) => void;}- HTTP opens an
EventSourceon the event’s SSE route. On an error it closes, waits a capped exponential backoff with jitter (0.5 s doubling to 30 s), and reconnects with the last seen id as theresumequery param. An initialargs.resumestarts from that id. - IPC calls
{fn}_subscribewith a new TauriChannel; the returned function calls{fn}_unsubscribe. onLagfires when the server dropped events for this subscriber.onOpenfires on every (re)connect. Together they are where a consumer runs its own catch-up read; the generated client does no domain catch-up.
Parameterless sync event ops also get an onX(callback) method, which passes an untyped payload and does not report lag. Prefer subscribeX.
Route prefix in client code
Section titled “Route prefix in client code”Route prefixes apply to the HttpTauriIpcSplit transport only; the HttpTs client does not support them, so against a server generated with a route prefix its calls do not reach the scoped routes. When route_prefix is configured, the split transport’s methods take an optional project ID after their own parameters and before options, and the HTTP transport puts the path under the prefix with scopedPath:
async taskList(projectId?: string, options?: ListOptions<TaskSortKey>): Promise<Task[]> { const { data } = await httpGet<JsonApiCollectionDocument>(scopedPath(projectId, `/tasks${toQueryString({ sort: options?.sort })}`)); return data.map(flattenTask);},Without a project ID the call goes to the unscoped route. For the IPC transport, the project ID is passed as an additional argument to invoke:
async taskList(projectId?: string, options?: ListOptions<TaskSortKey>): Promise<Task[]> { return invoke('task_list', { sort: options?.sort, projectId: projectId ?? null });},Admin registry
Section titled “Admin registry”The AdminRegistry generator produces a TypeScript file with per-entity metadata for admin UIs. This is designed for generic admin interfaces that can render CRUD forms and tables without hand-written configuration.
// Auto-generated admin registry. DO NOT EDIT.
import type { AdminFieldDef, AdminEntityConfig } from '@ontogen/admin-types'
export const adminEntities: AdminEntityConfig[] = [ { key: 'workout_set', plural: 'workout_sets', label: 'Workout_set', pluralLabel: 'Workout_sets', idType: 'string', listMethod: 'workoutSetList', getMethod: 'workoutSetGetById', createMethod: 'workoutSetCreate', updateMethod: 'workoutSetUpdate', deleteMethod: 'workoutSetDelete', returnType: 'WorkoutSet', createInputType: 'CreateWorkoutSetInput', updateInputType: 'UpdateWorkoutSetInput', paginated: false, fields: [ { key: 'id', label: 'Id', type: 'string', required: true, showInTable: true, showInDetail: true, showInForm: true, isId: true }, { key: 'workout_id', label: 'Workout Id', type: 'relation', required: true, relationTo: 'workout', showInTable: true, showInDetail: true, showInForm: true }, { key: 'exercise_id', label: 'Exercise Id', type: 'relation', required: true, relationTo: 'exercise', showInTable: true, showInDetail: true, showInForm: true }, { key: 'set_number', label: 'Set Number', type: 'number', required: true, showInDetail: true, showInForm: true }, { key: 'weight_grams', label: 'Weight Grams', type: 'number', required: true, showInDetail: true, showInForm: true }, { key: 'reps', label: 'Reps', type: 'number', required: true, showInDetail: true, showInForm: true }, { key: 'rpe', label: 'Rpe', type: 'number', showInDetail: true, showInForm: true }, { key: 'notes', label: 'Notes', type: 'string', showInDetail: true, showInForm: true }], }, // ... more entities];(From iron-log-md’s generated registry. The method names are the Transport methods the admin layer calls, so it works over HTTP and IPC alike.)
The registry only includes modules that have all five CRUD functions (list, get_by_id, create, update, delete). Partial or custom-only modules are excluded.
Per-field metadata
Section titled “Per-field metadata”When gen_clients is given the parsed schema (the Pipeline builder passes it from the schema stage), the admin generator emits detailed field information:
- type — the input kind:
string,number,boolean,enum(withenumValues),textfor the body,string-array,relationfor abelongs_to, andrelation-arrayfor ahas_manyormany_to_many - relationTo — for relation fields, the target entity’s module name
- required — whether the field is required vs optional
- display hints —
showInTable,showInDetail,showInForm,isId,isBodyandreadOnly
Relation fields are flat ids here, as on every Transport method, whatever the wire carries.
This lets admin UI frameworks render appropriate input components (text fields, dropdowns, relation pickers) without per-entity configuration.
Pagination metadata
Section titled “Pagination metadata”When pagination is configured, each entity config includes pagination details:
{ paginated: true, defaultLimit: 50, maxLimit: 200, // ...}Admin UIs can use this to render pagination controls with sensible defaults. The list method takes limit? and offset? and returns PaginatedResult<T>; the HTTP transport sends them as page[offset] and page[limit]. When the list takes a *Query filter struct, the entry also has listHasQuery: true, and the list method takes query? before the page.
ts_skip_commands
Section titled “ts_skip_commands”Some IPC commands shouldn’t appear in the TypeScript client — perhaps they’re internal or handled through a different mechanism. The ts_skip_commands list on ClientsConfig excludes specific commands by their canonical name:
ts_skip_commands: vec![ "system_health_check".to_string(), "internal_reindex".to_string(),],Skipped commands are omitted from both the HTTP client and the split transport. They still get server-side handlers (those are controlled by ServersConfig, which has its own dispatch).
How client functions mirror the server API
Section titled “How client functions mirror the server API”The client generators discover functions through the same ApiModule list that the server generators use. For each function, the generator:
- Determines the HTTP method and path from
OpKind. - Renders Rust parameter and return types to TypeScript through
ontogen_ts::render_type_str— the same classifier that emitsbindings.ts, so a parameter’s type in the client always matches the same type in the bindings. - Checks which TypeScript types are available in
bindings.ts. - Generates a function with matching name, parameters, and return type.
This means custom API endpoints you add through scan_dirs automatically get client functions too. You write the Rust function, rebuild, and the TypeScript client has a new typed function ready to call.
Parameter names and wire keys
Section titled “Parameter names and wire keys”A Rust argument’s TS parameter is named by the key Tauri reads it from: the name without its r#, lowerCamelCased with heck (project_id is projectId, _kind is kind, r#type is type). A name that is a JavaScript reserved word, or that a method body calls (invoke, callOp, flattenTask, …), takes a trailing _ (r#in is in_, new is new_). The keys a call sends are the wire’s whatever the parameter is called: over HTTP an argument travels in meta.args, opArg[…] or filter[…] under its serde name (type, in, _kind), and over IPC under its invoke key (type, in, kind):
async lookupFindDocs(type: string | null, in_: string | null, class_: string | null, kind: string | null): Promise<Doc[]> { return invoke('lookup_find_docs', { type, in: in_, class: class_, kind });},Two arguments with one invoke key (sort and _sort) are refused wherever the IPC server or the split transport is emitted. An event op’s arguments are members of its args object, named by their invoke keys. The bindings name an entity field as serde writes it, so r#loop is loop.
An entity may be named after a Web global the clients use: they write Error, Event, EventSource, Map, MessageEvent and Response as globalThis.…, and import Tauri’s Channel as IpcChannel. A type the clients import may not take the name of a global they use as it is (Array, JSON, Object, Promise, Record, String, and on the split transport Math and ReturnType) or of a name a client declares (PaginatedResult, ListOptions, an {Entity}SortKey, a JsonApi… type, and on the split transport Transport, SubscriptionHandlers, EventFrame and IpcChannel); the build fails naming the type.