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 (which also drives client artifacts via ServersConfig.client_generators) receives the merged ApiOutput and sees 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 async functions whose first parameter is either your state_type (e.g., &AppState) or your store_type (e.g., &Store).
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 (because it starts with get_ and has no input struct) and close_by_project as a CustomPost. 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 based on heuristics: functions starting with get_ or taking no parameters become GET endpoints, while functions taking an input struct become POST endpoints. 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.
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).
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.