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.
What gen_api produces
Section titled “What gen_api produces”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 taskspub async fn list(store: &Store, order: &[OrderBy<TaskSortField>]) -> Result<Vec<Task>, AppError> { store.list_tasks(order, None, None).await}
/// Get a single task by IDpub async fn get_by_id(store: &Store, id: &str) -> Result<Task, AppError> { store.get_task(id).await}
/// Create a new taskpub async fn create(store: &Store, input: CreateTaskInput) -> Result<Task, AppError> { store.create_task(Task::from(input)).await}
/// Update an existing taskpub async fn update(store: &Store, id: &str, input: UpdateTaskInput) -> Result<Task, AppError> { store.update_task(id, TaskUpdate::from(input)).await}
/// Delete a task by IDpub 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 epicspub 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.
ApiConfig options
Section titled “ApiConfig options”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 },)?;| Field | Type | Purpose |
|---|---|---|
output_dir | PathBuf | Where generated API modules are written |
exclude | Vec<String> | Entity names to skip (no API generation) |
scan_dirs | Vec<PathBuf> | Directories to scan for hand-written API modules |
state_type | String | The app state type name (e.g., "AppState") |
store_type | Option<String> | The store type name for function parameter scanning |
schema_module_path | String | Rust import path for schema types in generated code. Use ontogen::DEFAULT_SCHEMA_MODULE_PATH for the canonical default. |
paginated | Vec<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) |
The merge model
Section titled “The merge model”This is the key design feature of the API layer. gen_api produces a unified ApiOutput from two sources:
- Generated modules — the CRUD forwarding functions for each entity.
- Scanned modules — hand-written API files discovered in
scan_dirs.
Generated CRUD modules ──┐ ├──► Merged ApiOutputScanned 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.
How scan_dirs discovery works
Section titled “How scan_dirs discovery works”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 overduepub async fn get_overdue(store: &Store) -> Result<Vec<Task>, AppError> { // Custom query logic here todo!()}
/// Bulk-close tasks by projectpub 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.
Merge rules
Section titled “Merge rules”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.
The ApiModule and ApiFnMeta types
Section titled “The ApiModule and ApiFnMeta types”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.
The Source enum
Section titled “The Source enum”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.
When to exclude entities
Section titled “When to exclude entities”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() // clearerAnnotate the function with #[ontogen(rename = "...")] to override the emitted command name:
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(wasjournal_get_tag_history). - The TypeScript client method is
tagGetHistory()(wasjournalGetTagHistory()). - 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.
Config-side overrides
Section titled “Config-side overrides”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.
Event streams
Section titled “Event streams”The scanner also detects event stream functions. Any function returning a broadcast receiver type is classified as OpKind::EventStream:
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.
Service functions: accepted signatures
Section titled “Service functions: accepted signatures”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 wrote | Accepted? | Why |
|---|---|---|
state: &AppState | yes | Normalized type &AppState contains the substring AppState. |
state: &Arc<AppState> | yes | Normalized type &Arc<AppState> contains AppState. |
state: State<'_, Arc<AppState>> (Tauri) | yes | Substring 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>> | yes | Same: substring contains AppState. Use this only if your service signature is already shaped to be called with this type. |
state: State<'_, Mutex<OtherState>> | no | Stringified type doesn’t contain AppState or Store. Skipped, with a cargo:warning — see Build-time skip warnings. |
store: &Store | yes | Substring matches store_type = "Store". Classified as store-scoped (first_param_is_store = true). |
store: &StoreContext | yes (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) | no | No first parameter to match against. Surfaced as SkipReason::NoParams in build warnings. |
&self / self | no | API 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 parameter | Generated handler holds | Forwarded to service as |
|---|---|---|
name: String | name: String | name |
count: u8 | count: u8 | count |
id: MyId | id: MyId | id |
prefs: crate::schema::Prefs | (same, qualified) | prefs |
text: &str | text: String | &text |
id: &MyId | id: MyId | &id |
payload: &[u8] | payload: Vec<u8> | &payload |
p: &Path | p: ::std::path::PathBuf | &p |
s: &CStr | s: ::std::ffi::CString | &s |
s: &OsStr | s: ::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>).
Build-time skip warnings
Section titled “Build-time skip warnings”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.
Opting a file out: // ontogen:skip
Section titled “Opting a file out: // ontogen:skip”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::Stateargument (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](afteruse ontogen::stateless) and#[ontogen::stateless](fully qualified) are matched on the final path segment. Use whichever reads better in your module. &self/selfis 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 stateso the diagnostic points to the opt-in.
Singleton modules: // ontogen:singleton
Section titled “Singleton modules: // ontogen:singleton”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:singletonor//! ontogen:singleton, with the same placement rule as// ontogen:skip(leading comment-and-attribute block, before any item). NamingConfig::singleton_modules, aHashSet<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.
File organization
Section titled “File organization”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 forwardingThe 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.