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 (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.

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 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 (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.

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 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.

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.

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).

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.