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:

//! Generated by ontogen. DO NOT EDIT.
use crate::schema::AppError;
use crate::schema::Task;
use crate::schema::{CreateTaskInput, UpdateTaskInput};
use crate::store::task::TaskUpdate;
use crate::store::Store;
/// List all tasks
pub async fn list(store: &Store) -> Result<Vec<Task>, AppError> {
store.list_tasks().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> {
let task: Task = input.into();
store.create_task(task).await
}
/// Update an existing task
pub async fn update(store: &Store, id: &str, input: UpdateTaskInput) -> Result<Task, AppError> {
let updates: TaskUpdate = input.into();
store.update_task(id, updates).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.

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.

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(),
},
)?;
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.

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, first param is body-carrying for HTTP purposes). These get merged into the task module’s metadata alongside the generated CRUD functions.

When a scanned module has the same name as a generated module (like task), the scanned functions are folded into the existing module. If a scanned function has the same name as a generated one, the generated version wins — you can’t override list or create through scanning.

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
}

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. Junction operations (JunctionList, JunctionAdd, JunctionRemove) are recognized for list_*/add_*/remove_* functions that take a parent ID plus a child ID and produce 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()
},
// ...
};

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 SSE endpoints in the HTTP generator and Tauri event forwarding in the IPC generator. They don’t get MCP tools (MCP is request-response only).

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. Silently skipped today — see Build-time skip warnings to surface it.
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: PathBuf&p
s: &CStrs: CString&s
s: &OsStrs: 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<PathBuf>p.as_deref()
s: Option<&CStr>s: Option<CString>s.as_deref()
s: Option<&OsStr>s: Option<OsString>s.as_deref()
profile: Option<&MyStruct>profile: Option<MyStruct>profile.as_ref()

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 uses it today via NamingConfig::url_for_module; admin and doc-gen will read the same bit when they need to branch on collection-vs-singleton presentation).

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.