Custom API Endpoints
Generated CRUD covers the standard five operations: list, get, create, update, delete. But real applications need more — bulk operations, filtered queries, aggregations, business logic endpoints. Ontogen handles this by scanning your hand-written API modules and merging them into the same pipeline as generated code.
The result: your custom endpoints show up in Axum routes, Tauri IPC commands, and MCP tools automatically. No extra wiring.
How it works
Section titled “How it works”gen_api() does two things:
- Generates CRUD modules from entity definitions (written to
generated/). - Scans directories you specify for hand-written modules (parsed with
syn).
Both types are normalized into the same ApiModule / ApiFnMeta types. Downstream generators (servers, clients) see no difference between generated and custom endpoints.
The scan happens at build time. When you add a new .rs file to a scan directory, the next cargo build picks it up.
Recipe: Add a custom endpoint
Section titled “Recipe: Add a custom endpoint”-
Create the module file
Section titled “Create the module file”Put your custom endpoint in the API directory alongside the
generated/subdirectory. For example, if your generated CRUD for workouts lives atsrc/api/v1/generated/workout.rs, create a custom module atsrc/api/v1/workout_stats.rs:src/api/v1/workout_stats.rs use crate::schema::{AppError, Workout};use crate::store::Store;/// Get the total number of workouts in the last 30 dayspub async fn recent_count(store: &Store) -> Result<i64, AppError> {store.count_recent_workouts(30).await}/// Get the heaviest workout set across all exercisespub async fn personal_best(store: &Store,exercise_id: &str,) -> Result<Option<Workout>, AppError> {store.get_personal_best(exercise_id).await}Two things matter here:
- First parameter type: the scanner checks whether the first parameter is your
AppStatetype or yourStoretype (as configured inApiConfig). This determines scoping —Store-scoped functions get entity routes,AppState-scoped functions get app-level routes. - Doc comments: the
///comments become thedocfield inApiFnMeta. MCP tools use this as the tool description. Future OpenAPI generation will use it too. Write useful descriptions.
- First parameter type: the scanner checks whether the first parameter is your
-
Register in
Section titled “Register in mod.rs”mod.rsAdd the module to your API directory’s
mod.rs:src/api/v1/mod.rs pub mod generated;pub mod workout_stats; // your custom module -
Configure scan directories
Section titled “Configure scan directories”Make sure your
ApiConfigincludes the directory to scan:let api = gen_api(&schema.entities, &ApiConfig {output_dir: "src/api/v1/generated".into(),exclude: vec![],scan_dirs: vec!["src/api/v1".into()], // scans for hand-written modulesstate_type: "AppState".to_string(),store_type: Some("Store".to_string()),schema_module_path: ontogen::DEFAULT_SCHEMA_MODULE_PATH.into(),})?;With the
Pipelinebuilder, this is.api("src/api/v1/generated", "AppState").api_scan_dirs(vec!["src/api/v1".into()]).The scanner reads every
.rsfile insrc/api/v1/(excluding thegenerated/subdirectory) and extracts function signatures. -
Terminal window cargo buildThe scanner parses your new file, extracts
recent_countandpersonal_best, classifies them, and includes them inApiOutput. Downstream generators produce handlers for them. -
Check the generated transports
Section titled “Check the generated transports”After the build, look at your generated transport files. Your custom functions should appear alongside the CRUD handlers.
HTTP routes:
recent_countbecomes aGETendpoint (no body parameter, classified asCustomGet).personal_bestis alsoGETbecause its first parameter (exercise_id: i64) is an id-like primitive that fits in a URL path segment.IPC commands: both functions get
#[tauri::command]wrappers.MCP tools: both get tool definitions with your doc comments as descriptions.
Function classification rules
Section titled “Function classification rules”The scanner classifies each function into an OpKind that drives HTTP verb and route structure. Classification first looks at the function name (matches against the CRUD vocabulary and the junction-table conventions); for custom functions that don’t match a vocabulary entry, the classifier consults the first user-facing parameter’s type to pick CustomGet vs CustomPost.
CRUD and junction vocabulary
Section titled “CRUD and junction vocabulary”| Function name | OpKind | HTTP | Route example |
|---|---|---|---|
list | List | GET | /api/workouts |
get_by_id | GetById | GET | /api/workouts/:id |
create | Create | POST | /api/workouts |
update | Update | PUT | /api/workouts/:id |
delete | Delete | DELETE | /api/workouts/:id |
add_{child}(parent_id, child_id) | JunctionAdd | POST | /api/workouts/:parent_id/{child} |
remove_{child}(parent_id, child_id) | JunctionRemove | DELETE | /api/workouts/:parent_id/{child}/:child_id |
list_{children}(parent_id) | JunctionList | GET | /api/workouts/:parent_id/{children} |
Custom functions (CustomGet / CustomPost)
Section titled “Custom functions (CustomGet / CustomPost)”For names that don’t match a vocabulary entry, the classifier looks at the shape of the first user-facing parameter:
| Function shape | OpKind | HTTP | How parameters are extracted |
|---|---|---|---|
| Zero user-facing parameters | CustomGet | GET | (no extraction) |
get_{name}(state, id: &str) (or String, integer, Uuid, …) | CustomGet | GET | First param → URL path segment (/api/.../:id) |
get_{name}(state, q: Option<…>) | CustomGet | GET | Option<…> params → query string |
get_{name}(state, body: &CustomStruct) | CustomPost | POST | Body-carrying first param → JSON body |
| Any other shape | CustomPost | POST | Non-Option non-*Input params → JSON body; Option<…> → query |
Returns tokio::sync::broadcast::Receiver<T> | EventStream | GET (SSE) | /api/events/{event-name} |
The id-like primitive allowlist is: bool, char, i8–i128, isize, u8–u128, usize, f32, f64, String, str, and Uuid. References to any of these (&str, &String) are also path-extractable. Anything else (a single-segment custom struct, a fully-qualified path like crate::schema::Foo, or a generic container like Vec<T> / HashMap<K, V>) is treated as body-carrying.
For custom functions, the module name becomes part of the route path. The function name is kebab-cased for the URL segment.
Organizing custom endpoints
Section titled “Organizing custom endpoints”There’s no enforced file structure — the scanner just reads .rs files from your scan directories. But here are patterns that work well:
Per-entity custom operations: put them in a file named after the entity (not in generated/):
src/api/v1/ generated/ # ontogen writes here workout.rs # list, get_by_id, create, update, delete workout.rs # your custom workout operations (archive, duplicate) mod.rsCross-entity operations: use a descriptive module name:
src/api/v1/ generated/ reports.rs # functions that query across multiple entities import_export.rs # bulk import/export operations mod.rsDomain-specific modules: group by business function:
src/api/v1/ generated/ training_plans.rs # training plan operations progress.rs # progress tracking queries mod.rsWriting scannable functions
Section titled “Writing scannable functions”For the scanner to pick up your function correctly, follow these conventions:
First parameter must be the state or store type:
// Store-scoped (entity operations)pub async fn archive(store: &Store, id: &str) -> Result<(), AppError> { ... }
// AppState-scoped (app-level operations)pub async fn health_check(state: &AppState) -> Result<String, AppError> { ... }The parser accepts the function when the rendered first-parameter type contains your configured state_type or store_type as a substring. &AppState, &Arc<AppState>, and State<'_, Arc<AppState>> all match state_type = "AppState". See Service functions: accepted signatures for the full table including edge cases.
Return type must be Result<T, AppError> (or whatever your error type is):
pub async fn count(store: &Store) -> Result<i64, AppError> { ... }Parameters after the state/store are extracted as route params or query params:
// `id` becomes a path parameter: GET /api/workouts/:id/setspub async fn get_sets(store: &Store, id: &str) -> Result<Vec<WorkoutSet>, AppError> { ... }
// `input` becomes a JSON body: POST /api/workouts/importpub async fn import(store: &Store, input: ImportWorkoutsInput) -> Result<Vec<Workout>, AppError> { ... }You can take parameters by reference (&str, &[u8], &Path, &CStr, &OsStr, &MyStruct) or wrap them in Option<…>. The generator declares the matching sized owned type in the handler and forwards the borrow back to your function. See Parameter types: references and Option shapes for the full table.
Use doc comments for descriptions:
/// Archive a workout. Archived workouts are hidden from the default list/// but can be restored later.pub async fn archive(store: &Store, id: &str) -> Result<(), AppError> { ... }Opt out of the state-first-param rule with #[ontogen::stateless]:
Some operations genuinely don’t need state — clock reads, pure data transforms, OS-level side effects. Mark them with #[ontogen::stateless] and the parser will accept any (or no) first parameter, while the generators emit handlers without the State<...> extractor:
use ontogen::stateless;
// Zero params — handler is `fn util_now() -> Result<i64, String>`#[stateless]pub fn now() -> Result<i64, AppError> { Ok(chrono::Utc::now().timestamp())}
// Non-state first param — handler signature is just `text: String`#[stateless]pub fn copy(text: &str) -> Result<(), AppError> { clipboard::copy_text(text.to_string()).map_err(AppError::DbError)}See Stateless utility functions for the full behaviour.
Force POST classification with #[ontogen::http::post]:
The auto-classifier routes zero-user-param custom functions as GET (the empty-params branch in classify_by_name_and_params — no body to carry). That heuristic is wrong for action-verb mutating handlers like pause(state), resume(state), or reset_all(state) — they mutate state but emit as GET because they have no input. Use #[ontogen::http::post] to flip a single handler to POST without changing the global default:
use ontogen::http::post;
// Without the attribute, `pause(state)` would emit as `get(...)` because// after the state strip it has zero user-input params.#[post]pub async fn pause(state: &AppState) -> Result<(), AppError> { state.engine.pause().await}The attribute lives under the http::* namespace because the override is specifically HTTP-method-shape; routing-shape-agnostic attributes (stateless, rename, skip) stay at the top of the ontogen:: namespace. The same attribute parser also accepts the bare #[post] form (after a use ontogen::http::post;) or any path ending in ::post — match is on the final path segment.
The attribute is purely additive: omit it and the existing classifier behaviour applies (zero-param custom fns → CustomGet, named-CRUD by name, body-carrying first params → CustomPost, etc.). The macro itself is a no-op; the ontogen parser reads it via syn during build-time scanning and stamps ApiFn::force_method = Some(ForcedMethod::Post), which classify_op short-circuits on to return OpKind::CustomPost.
Combine freely with #[ontogen::stateless] and #[ontogen(rename = "…")] — order doesn’t matter, the parser inspects each attribute independently.
Rerun directives
Section titled “Rerun directives”Don’t forget to set up cargo:rerun-if-changed for your custom API directory. In your build.rs:
ontogen::emit_rerun_directives_excluding( &PathBuf::from("src/api/v1"), &["generated"],);This watches all files in src/api/v1/ except the generated/ subdirectory. When you edit a custom endpoint, Cargo re-runs the build script and the scanner picks up your changes.