Skip to content

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.

gen_api() does two things:

  1. Generates CRUD modules from entity definitions (written to generated/).
  2. 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.

  1. Put your custom endpoint in the API directory alongside the generated/ subdirectory. For example, if your generated CRUD for workouts lives at src/api/v1/generated/workout.rs, create a custom module at src/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 days
    pub async fn recent_count(store: &Store) -> Result<i64, AppError> {
    store.count_recent_workouts(30).await
    }
    /// Get the heaviest workout set across all exercises
    pub 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 AppState type or your Store type (as configured in ApiConfig). This determines scoping — Store-scoped functions get entity routes, AppState-scoped functions get app-level routes.
    • Doc comments: the /// comments become the doc field in ApiFnMeta. MCP tools use this as the tool description. Future OpenAPI generation will use it too. Write useful descriptions.
  2. Add the module to your API directory’s mod.rs:

    src/api/v1/mod.rs
    pub mod generated;
    pub mod workout_stats; // your custom module
  3. Make sure your ApiConfig includes 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 modules
    state_type: "AppState".to_string(),
    store_type: Some("Store".to_string()),
    schema_module_path: ontogen::DEFAULT_SCHEMA_MODULE_PATH.into(),
    })?;

    With the Pipeline builder, this is .api("src/api/v1/generated", "AppState").api_scan_dirs(vec!["src/api/v1".into()]).

    The scanner reads every .rs file in src/api/v1/ (excluding the generated/ subdirectory) and extracts function signatures.

  4. Terminal window
    cargo build

    The scanner parses your new file, extracts recent_count and personal_best, classifies them, and includes them in ApiOutput. Downstream generators produce handlers for them.

  5. After the build, look at your generated transport files. Your custom functions should appear alongside the CRUD handlers.

    HTTP routes: recent_count becomes a GET endpoint (no body parameter, classified as CustomGet). personal_best is also GET because 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.

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.

Function nameOpKindHTTPRoute example
listListGET/api/workouts
get_by_idGetByIdGET/api/workouts/:id
createCreatePOST/api/workouts
updateUpdatePUT/api/workouts/:id
deleteDeleteDELETE/api/workouts/:id
add_{child}(parent_id, child_id)JunctionAddPOST/api/workouts/:parent_id/{child}
remove_{child}(parent_id, child_id)JunctionRemoveDELETE/api/workouts/:parent_id/{child}/:child_id
list_{children}(parent_id)JunctionListGET/api/workouts/:parent_id/{children}

For names that don’t match a vocabulary entry, the classifier looks at the shape of the first user-facing parameter:

Function shapeOpKindHTTPHow parameters are extracted
Zero user-facing parametersCustomGetGET(no extraction)
get_{name}(state, id: &str) (or String, integer, Uuid, …)CustomGetGETFirst param → URL path segment (/api/.../:id)
get_{name}(state, q: Option<…>)CustomGetGETOption<…> params → query string
get_{name}(state, body: &CustomStruct)CustomPostPOSTBody-carrying first param → JSON body
Any other shapeCustomPostPOSTNon-Option non-*Input params → JSON body; Option<…> → query
Returns tokio::sync::broadcast::Receiver<T>EventStreamGET (SSE)/api/events/{event-name}

The id-like primitive allowlist is: bool, char, i8i128, isize, u8u128, 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.

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

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

Domain-specific modules: group by business function:

src/api/v1/
generated/
training_plans.rs # training plan operations
progress.rs # progress tracking queries
mod.rs

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/sets
pub async fn get_sets(store: &Store, id: &str) -> Result<Vec<WorkoutSet>, AppError> { ... }
// `input` becomes a JSON body: POST /api/workouts/import
pub 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.

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.