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:
//! 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 taskspub async fn list(store: &Store) -> Result<Vec<Task>, AppError> { store.list_tasks().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> { let task: Task = input.into(); store.create_task(task).await}
/// Update an existing taskpub 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 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.
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.
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(), },)?;| 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. |
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, first param is body-carrying for HTTP purposes). These get merged into the task module’s metadata alongside the generated CRUD functions.
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. 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.
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}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.
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() }, // ...};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 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).
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. Silently skipped today — see Build-time skip warnings to surface it. |
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: PathBuf | &p |
s: &CStr | s: CString | &s |
s: &OsStr | s: 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>).
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 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).
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.