Skip to content

API Layer

The API layer sits between your Store and your transport layer. gen_api produces thin forwarding functions that call store methods, scans for hand-written API modules, and merges everything into a unified ApiOutput that downstream generators consume.

For each entity, gen_api generates a module with five functions. For an unpaginated task module:

//! Generated by ontogen. DO NOT EDIT.
use ontogen_core::order::OrderBy;
use crate::schema::AppError;
use crate::schema::Task;
use crate::schema::{CreateTaskInput, UpdateTaskInput};
use crate::store::Store;
use crate::store::task::{TaskSortField, TaskUpdate};
/// List all tasks
pub async fn list(store: &Store, order: &[OrderBy<TaskSortField>]) -> Result<Vec<Task>, AppError> {
store.list_tasks(order, None, None).await
}
/// Get a single task by ID
pub async fn get_by_id(store: &Store, id: &str) -> Result<Task, AppError> {
store.get_task(id).await
}
/// Create a new task
pub async fn create(store: &Store, input: CreateTaskInput) -> Result<Task, AppError> {
store.create_task(Task::from(input)).await
}
/// Update an existing task
pub async fn update(store: &Store, id: &str, input: UpdateTaskInput) -> Result<Task, AppError> {
store.update_task(id, TaskUpdate::from(input)).await
}
/// Delete a task by ID
pub async fn delete(store: &Store, id: &str) -> Result<(), AppError> {
store.delete_task(id).await
}

These are intentionally thin. The forwarding pattern keeps a clean separation: the Store owns the data access logic (with hooks), while the API layer provides the interface that transport generators consume.

list takes the order that sort is read into. In a paginated module (ApiConfig::paginated), list takes limit and offset after it and returns one page, and a sixth function, count, returns the total behind it:

/// One page of epics
pub async fn list(
store: &Store,
order: &[OrderBy<EpicSortField>],
limit: Option<u64>,
offset: Option<u64>,
) -> Result<Vec<Epic>, AppError> {
store.list_epics(order, limit, offset).await
}
/// How many epics there are — the total behind a page of `list`
pub async fn count(store: &Store) -> Result<u64, AppError> {
store.count_epics().await
}

See Ordering for the order, and Pagination support for how the page reaches the wire.

The create and update functions accept DTO types (CreateTaskInput, UpdateTaskInput) and convert them to domain types before calling the store. This conversion layer is where Ontogen handles the translation between what clients send and what your store expects.

Over HTTP, these functions become the CRUD routes of a JSON:API resource. The generated server builds the resource object from the schema entity, not from the function’s Rust return type, and reads request documents back into the DTOs, so the forwarding functions stay flat: Task in, Task out. IPC and MCP call the same functions with flat payloads. Errors that the functions return as AppError reach HTTP clients as JSON:API error documents, with the status and code taken from the variant name (TaskNotFound becomes 404 task_not_found). The mapping and the variants your AppError must declare are in Server Transports.

let api_output = ontogen::gen_api(
&schema.entities,
&ontogen::ApiConfig {
output_dir: "src/api/v1/generated".into(),
exclude: vec!["InternalAuditLog".to_string()],
scan_dirs: vec!["src/api/v1".into()],
state_type: "AppState".to_string(),
store_type: Some("Store".to_string()),
schema_module_path: ontogen::DEFAULT_SCHEMA_MODULE_PATH.into(),
paginated: vec![], // modules whose `list` takes a page
},
)?;
FieldTypePurpose
output_dirPathBufWhere generated API modules are written
excludeVec<String>Entity names to skip (no API generation)
scan_dirsVec<PathBuf>Directories to scan for hand-written API modules
state_typeStringThe app state type name (e.g., "AppState")
store_typeOption<String>The store type name for function parameter scanning
schema_module_pathStringRust import path for schema types in generated code. Use ontogen::DEFAULT_SCHEMA_MODULE_PATH for the canonical default.
paginatedVec<String>Modules (snake_case entity names) whose list takes limit and offset and gets a count. A surface whose pagination is set needs every module it pages listed here; Pipeline sets it with .api_paginated(..) (see ApiConfig)

This is the key design feature of the API layer. gen_api produces a unified ApiOutput from two sources:

  1. Generated modules — the CRUD forwarding functions for each entity.
  2. Scanned modules — hand-written API files discovered in scan_dirs.
Generated CRUD modules ──┐
├──► Merged ApiOutput
Scanned hand-written ────┘

Downstream generation in gen_servers (Rust transports) and gen_clients (TypeScript clients + admin registry) both receive the same merged ApiOutput and see no difference between generated and hand-written functions. A custom endpoint you wrote by hand gets HTTP routes, IPC commands, MCP tools, and TypeScript client functions just like the generated CRUD operations.

When you set scan_dirs, gen_api parses each .rs file in those directories using syn. It looks for public functions whose first parameter is either your state_type (e.g., &AppState) or your store_type (e.g., &Store).

The acceptance rule is a literal substring match: the parser stringifies the first parameter’s type and accepts the function when that string contains the configured state_type (or store_type) as a substring. This is permissive on purpose — &AppState, state: &AppState, and &Arc<AppState> all pass — but it means a few subtle shapes are accepted-by-substring (&AppStateLite would match state_type="AppState") and a few are rejected even though they look correct (State<'_, Mutex<AppState>> rejects when state_type="PumiceState"). See Service functions: accepted signatures below for the full table.

For example, if you have a hand-written src/api/v1/task.rs alongside the generated src/api/v1/generated/task.rs:

// src/api/v1/task.rs (hand-written)
use crate::schema::{Task, AppError};
use crate::store::Store;
/// Get tasks that are overdue
pub async fn get_overdue(store: &Store) -> Result<Vec<Task>, AppError> {
// Custom query logic here
todo!()
}
/// Bulk-close tasks by project
pub async fn close_by_project(store: &Store, project_id: &str) -> Result<(), AppError> {
// Custom mutation logic here
todo!()
}

The scanner picks up both functions, classifies get_overdue as a CustomGet (zero user-facing parameters, no body to extract) and close_by_project as a CustomPost (no name match in the CRUD vocabulary). These get merged into the task module’s metadata alongside the generated CRUD functions.

Over HTTP they are custom ops, which carry meta-only JSON:API documents. GET /api/tasks/overdue answers {"jsonapi": {"version": "1.1"}, "meta": {"result": [...]}}, where the tasks are plain serde values, not resource objects. POST /api/tasks/close-by-project takes its arguments by name, {"meta": {"args": {"project_id": "..."}}}, and answers 204 because the function returns (). See Operations that are not CRUD.

When a scanned module has the same name as a generated module (like task), the scanned functions are folded into the existing module. Only list and count may share a name with a generated function: a hand-written list or count replaces the generated one. Any other name the generated module defines, written again by hand, fails the build as defined twice in that API directory. gen_api scans before it emits, so it does not write the replaced fns. In a paginated module a hand-written list also replaces the generated count, so you write a count that takes the same filter (see Filtered lists).

A hand-written list answers sort when it takes order: &[OrderBy<{Entity}SortField>], after its filter parameters and before limit and offset, and orders through list_*, sort_{plural} or order_{plural}_query. Without the parameter, sort is 400 invalid_sort_field. See Filtered lists.

When a scanned module has no generated counterpart (a purely custom module), it’s added as a new entry in ApiOutput.

ApiOutput contains a list of ApiModules, each containing a list of ApiFnMetas:

pub struct ApiOutput {
pub modules: Vec<ApiModule>,
}
pub struct ApiModule {
pub name: String, // e.g., "task"
pub fns: Vec<ApiFnMeta>, // all functions in this module
pub state_type: StateKind, // Store or AppState
}
pub struct ApiFnMeta {
pub name: String, // e.g., "list", "get_by_id", "get_overdue"
pub doc: String, // doc comment
pub params: Vec<ParamMeta>, // parameter names and types
pub return_type: String, // e.g., "Vec<Task>"
pub source: Source, // Generated or Scanned
pub classified_op: OpKind, // see ontogen_core::ir::OpKind
pub is_stateless: bool, // #[ontogen::stateless]
pub command_override: Option<String>, // #[ontogen(rename = "...")] or NamingConfig::command_overrides
}

The OpKind classification drives how downstream generators produce routes and commands. The standard CRUD operations (List, GetById, Create, Update, Delete) get predictable HTTP methods and paths (GET, GET, POST, PATCH, DELETE), and over HTTP they are served as JSON:API resources when the module is backed by a schema entity — see Server Transports. Junction operations (JunctionList, JunctionAdd, JunctionRemove) are recognized for add_*/remove_* functions that take a parent ID plus a child ID, and for a list_* function that takes a parent ID when its module also has the add_* or remove_* of the same relationship (list_tags beside add_tag); a lone one-parameter list_* is a CustomGet. In a module named after an entity, junction ops define a to-many relationship that HTTP serves on its relationship endpoints (/api/tasks/{id}/relationships/labels, see Relationships); in any other module they are custom ops at nested routes like /api/agents/{parent_id}/roles. Custom operations are classified as either CustomGet or CustomPost: the classifier first checks the function name (so get_* is a CustomGet candidate, anything else defaults to CustomPost), then consults the AST of the first user-facing parameter — a get_* function whose first param is a body-carrying custom struct gets reclassified as CustomPost so the HTTP transport can emit a JSON body instead of an unworkable Path<String> extraction. See the function classification table in the custom endpoints cookbook for the full ruleset. Event-stream functions are classified as EventStream.

Every function carries a Source tag:

pub enum Source {
Generated { module_path: String },
Scanned { module_path: String, file_path: PathBuf },
}

This tells downstream generators where to import the function from. Generated functions live under crate::api::v1::generated::{entity}, while scanned functions reference crate::api::v1::{module}. The transport generators use this to emit correct use statements.

The exclude list removes entities from API generation entirely. Use it for:

  • Internal entities that should never be exposed over HTTP/IPC (audit logs, system config).
  • Entities with fully custom APIs where the generated CRUD doesn’t match your needs.
  • Junction entities that are managed through parent entity endpoints instead of direct CRUD.
exclude: vec![
"AuditLog".to_string(), // internal only
"SessionToken".to_string(), // security-sensitive, custom API
],

Excluded entities still get store-layer code (CRUD methods, hooks). They just don’t get API forwarding functions or downstream transport handlers.

Renaming a command: #[ontogen(rename = "...")]

Section titled “Renaming a command: #[ontogen(rename = "...")]”

By default the IPC command and TypeScript client method for a function are derived as {entity}_{fn_name} (where entity is the singular form of the module name). This is correct when the module name is the noun the operations are about, but reads awkwardly when the function name already encodes the noun:

journal::get_tag_history → journalGetTagHistory() // redundant
← tagGetHistory() // clearer

Annotate the function with #[ontogen(rename = "...")] to override the emitted command name:

src/api/v1/journal.rs
use ontogen::ontogen;
#[ontogen(rename = "tag_get_history")]
pub fn get_tag_history(store: &Store, tag: &str) -> Result<Vec<HistoryEntry>, Error> {
// ... unchanged
}

After this change:

  • The Tauri IPC command is tag_get_history (was journal_get_tag_history).
  • The TypeScript client method is tagGetHistory() (was journalGetTagHistory()).
  • The HTTP route path, the underlying Rust function journal::get_tag_history, and any generated query/body struct names are unchanged. The override is a naming fix for IPC and TypeScript surfaces only.

The macro is a no-op at compile time — it just makes the attribute legal Rust so rust-analyzer and rustdoc see the original signature. The build script reads it via syn.

When the source can’t be modified — for example, generated CRUD functions or third-party API modules — use NamingConfig::command_overrides to override commands from the build script:

use std::collections::HashMap;
let config = ServersConfig {
naming: NamingConfig {
command_overrides: HashMap::from([
("journal::get_tag_history".to_string(), "tag_get_history".to_string()),
]),
..Default::default()
},
// ...
extra_surfaces: vec![],
error_source_dir: None,
};

Keys are "module::fn_name". Source wins: if a function carries both a #[ontogen(rename = "...")] attribute and a matching config entry, the attribute is preserved and the config entry is silently ignored. The attribute is co-located with the function, so a reader looking at the source has the full naming story; the config map is an escape hatch.

The scanner also detects event stream functions. Any function returning a broadcast receiver type is classified as OpKind::EventStream:

src/api/v1/task.rs
pub fn task_updates(state: &AppState) -> broadcast::Receiver<TaskDelta> {
state.event_bus().subscribe_tasks()
}

Event streams get an SSE route in the HTTP generator, whose data: payload is the item’s resource object when the item is a schema entity and {"meta": {"result": …}} otherwise, and per-subscriber {fn}_subscribe / {fn}_unsubscribe commands in the IPC generator, whose frames are flat. They don’t get MCP tools (MCP is request-response only). Parameters, resume and lag are covered in Event subscriptions.

The parser is permissive about how you write the first parameter as long as the rendered type contains your configured state_type (or store_type) somewhere. Most natural shapes work; a handful of edge cases need to be understood.

Assume state_type = "AppState" and store_type = Some("Store") for every row.

First parameter the user wroteAccepted?Why
state: &AppStateyesNormalized type &AppState contains the substring AppState.
state: &Arc<AppState>yesNormalized type &Arc<AppState> contains AppState.
state: State<'_, Arc<AppState>> (Tauri)yesSubstring match still succeeds. The parser accepts the function, but the generated handlers expect a plain &AppState first param — your service function should match.
state: tauri::State<'_, Mutex<AppState>>yesSame: substring contains AppState. Use this only if your service signature is already shaped to be called with this type.
state: State<'_, Mutex<OtherState>>noStringified type doesn’t contain AppState or Store. Skipped, with a cargo:warning — see Build-time skip warnings.
store: &StoreyesSubstring matches store_type = "Store". Classified as store-scoped (first_param_is_store = true).
store: &StoreContextyes (footgun)"Store" is a substring of "StoreContext", so the parser treats this fn as store-scoped. Pick distinctive store_type names to avoid this.
(no parameters)noNo first parameter to match against. Surfaced as SkipReason::NoParams in build warnings.
&self / selfnoAPI modules host free functions, not methods. Surfaced as SkipReason::SelfReceiver.

Parameter types: references and Option shapes

Section titled “Parameter types: references and Option shapes”

The first parameter is your state or store handle. Every subsequent parameter becomes a handler argument — declared as the owned form in the generated handler struct, then forwarded back to your service function in the shape you wrote.

The mapping is deliberate: any &T you can write is paired with the matching sized owned type, so the handler can hold the owned value and convert back to a borrow at the call site. Five unsized DSTs are recognized explicitly because their bare form (str, [u8], Path, CStr, OsStr) is not valid as a struct field; the generator substitutes the sized companion:

User-written parameterGenerated handler holdsForwarded to service as
name: Stringname: Stringname
count: u8count: u8count
id: MyIdid: MyIdid
prefs: crate::schema::Prefs(same, qualified)prefs
text: &strtext: String&text
id: &MyIdid: MyId&id
payload: &[u8]payload: Vec<u8>&payload
p: &Pathp: ::std::path::PathBuf&p
s: &CStrs: ::std::ffi::CString&s
s: &OsStrs: ::std::ffi::OsString&s
rating: Option<u8>rating: Option<u8>rating
name: Option<String>name: Option<String>name
name: Option<&str>name: Option<String>name.as_deref()
bytes: Option<&[u8]>bytes: Option<Vec<u8>>bytes.as_deref()
p: Option<&Path>p: Option<::std::path::PathBuf>p.as_deref()
s: Option<&CStr>s: Option<::std::ffi::CString>s.as_deref()
s: Option<&OsStr>s: Option<::std::ffi::OsString>s.as_deref()
profile: Option<&MyStruct>profile: Option<MyStruct>profile.as_ref()

The generated files import nothing from std, so they name these companions by path. A bare type name your signature writes, PathBuf or Path included, is imported from your types module like your own types: a function taking a PathBuf by value needs pub use std::path::PathBuf; there, and an entity named Path is that entity, in the TypeScript clients too. Behind a reference, &Path is always std::path::Path.

Vec<&str> and other container types holding refs are passed through as-written. If you need an owned variant inside a container, write it explicitly (Vec<String> rather than Vec<&str>).

When gen_api (or gen_servers) encounters a pub fn that doesn’t satisfy the rule above, it emits one cargo:warning= line per dropped function. The wording is one of:

warning: ontogen: skipped fn `foo::rename` in `src/api/v1/foo.rs` -
first param `tauri::State<'_, Mutex<OtherState>>` does not match
state_type 'AppState' or store_type 'Store'
warning: ontogen: skipped fn `foo::cache_clear` in `src/api/v1/foo.rs` -
fn has no parameters
warning: ontogen: skipped fn `foo::helper` in `src/api/v1/foo.rs` -
first param is `self`/`&self`

Private (fn without pub) functions, items that aren’t functions (struct, enum, use, …), and mod.rs files never produce a warning — those are intentional omissions, not silently-dropped API endpoints. Event functions (returning broadcast::Receiver<T>) are classified as events, not skipped.

If you see a warning for a function you did mean as an API endpoint, the fix is almost always either renaming the configured state_type / store_type to match what you actually wrote, or adjusting the first parameter to use the canonical &AppState / &Store shape so downstream generators can call it cleanly.

If a file under your api directory is a pure helper module (shared utilities, not transport-eligible endpoints), you can opt the whole file out of scanning with a file-level marker. Either form is accepted:

  • // ontogen:skip (plain line comment)
  • //! ontogen:skip (inner doc comment, including embedded inside a multi-line //! block)

The marker must appear in the file’s leading comment-and-attribute block — before any use, pub fn, or other item. When present, the file is dropped from ScanResult.modules and no per-fn cargo:warning= lines are emitted for anything in it (opt-out is intentional, so the diagnostics are silenced too).

Stateless utility functions: #[ontogen::stateless]

Section titled “Stateless utility functions: #[ontogen::stateless]”

Most service functions take your state_type (or store_type) as their first parameter so the generated handler can thread state through. Some functions don’t need any state at all — pure data transformations, OS-level side effects, clock reads. Forcing those to declare a _state: &AppState parameter just to pass the parser produces signatures shaped by the parser’s constraints rather than by what the function actually needs.

The #[ontogen::stateless] attribute opts a function out of the state-first-param rule:

use ontogen::stateless;
#[stateless]
pub fn copy(text: &str) -> Result<(), AppError> {
pumice_desktop::clipboard::copy_text(text.to_string())
.map_err(AppError::DbError)
}
#[ontogen::stateless]
pub fn now() -> Result<i64, AppError> {
Ok(chrono::Utc::now().timestamp())
}

The attribute itself is a no-op proc-macro: the function body and signature pass through unchanged at expansion time. The ontogen parser reads it at build time and emits handler shapes that:

  • Omit the State<...> extractor (HTTP) / tauri::State argument (IPC).
  • Skip Store construction and prefix validation.
  • Forward only the user-declared parameters to the service function — no positional state argument is inserted.

Routing is unchanged: stateless functions are nested under their module URL the same as state-bearing functions. pub fn copy(...) in src/api/v1/clipboard.rs becomes the IPC command clipboard_copy and the HTTP route /api/clipboards/copy, with the module URL transformed per the usual rules (use // ontogen:singleton if the module is a singleton and you want /api/clipboard/copy instead).

Three other things to know:

  • Either path form is accepted. #[stateless] (after use ontogen::stateless) and #[ontogen::stateless] (fully qualified) are matched on the final path segment. Use whichever reads better in your module.
  • &self / self is still rejected. Methods don’t fit free-function API modules regardless of statelessness.
  • Without the attribute, the same fn is silently dropped. Build warnings (see above) include add #[ontogen::stateless] if this fn intentionally takes no state so the diagnostic points to the opt-in.

Some api modules represent a single entity rather than a collection — database, autostart, vault. Declare them as singletons so the HTTP generator emits singular kebab URLs (/api/database/path) instead of the default pluralized form (/api/databases/path). Two declaration mechanisms are accepted; both feed into the same ApiModule::is_singleton IR field:

  • A file-level marker — // ontogen:singleton or //! ontogen:singleton, with the same placement rule as // ontogen:skip (leading comment-and-attribute block, before any item).
  • NamingConfig::singleton_modules, a HashSet<String> of module names in your build-script config. Useful when you don’t own the source file or want to flip the bit centrally.

Both inputs are OR’d together by a post-parse overlay before generators run, so either side alone is sufficient. The flag is exposed on ApiModule::is_singleton for downstream generators; HTTP reads it through NamingConfig::url_for_module.

A singleton module has no JSON:API resource type: every op in it is served as a custom op at /api/{singular}/{action}, with a meta-only document. An op in it that classifies as list, get_by_id, create, update, delete or a junction op fails the build when it generates an HTTP server or an HTTP TypeScript client.

After running gen_api with scan_dirs configured:

src/api/
v1/
mod.rs # your hand-written module declarations
task.rs # your custom task endpoints
analytics.rs # a purely custom module
generated/ # regenerated every build (DO NOT EDIT)
mod.rs
task.rs # generated CRUD forwarding
agent.rs # generated CRUD forwarding

The generated mod.rs re-exports all generated modules. Your hand-written src/api/v1/mod.rs imports both the generated and custom modules — this is what scan_dirs reads to discover the full API surface.