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, MCP tools and the TypeScript client automatically. No extra wiring. Over HTTP each one is a JSON:API custom op: its arguments travel in meta.args (or the URL, for a GET) and its result comes back in meta.result. Over IPC and MCP the arguments and the result stay flat.

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.

The iron-log example has one custom endpoint, a workout stats summary. This recipe walks through it.

  1. Put your custom endpoint in the API directory alongside the generated/ subdirectory. iron-log’s generated CRUD lives under src/api/v1/generated/, and the stats endpoint is src/api/v1/stats.rs:

    src/api/v1/stats.rs
    use crate::schema::{AppError, WorkoutStats};
    use crate::store::Store;
    /// Read-only stats summary.
    pub async fn get_workout(_store: &Store) -> Result<WorkoutStats, AppError> {
    Ok(WorkoutStats { total_count: 0, total_duration_minutes: 0 })
    }

    Three 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). A function that takes the store is served under the route prefix when one is configured; one that takes the state stays unscoped.
    • The name: get_workout takes no arguments after the store, and a function like that is a GET only when its name starts with a read prefix such as get_; otherwise it is a POST. See Function classification rules.
    • Doc comments: the /// comments become the doc field in ApiFnMeta. MCP tools use this as the tool description. Write useful descriptions.

    WorkoutStats lives in crate::schema, the types_import_path, because the generated handlers import every type an API function names from there.

  2. Add the module to your API directory’s mod.rs:

    src/api/v1/mod.rs
    pub mod activity;
    pub mod generated;
    pub mod stats; // your custom module
    pub use generated::*;
  3. The Pipeline builder scans the servers’ and clients’ api_dir (src/api/v1 in iron-log), so with it there is nothing to add; .api_scan_dirs(..) adds more directories. A direct gen_api caller lists the directory in ApiConfig::scan_dirs:

    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(),
    paginated: vec![],
    })?;

    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 stats.rs, extracts get_workout, classifies it as CustomGet, and includes it in ApiOutput. Downstream generators produce handlers for it.

  5. After the build, get_workout appears in every generated transport beside the CRUD handlers:

    • HTTP: GET /api/stats/workout, served by stat_get_workout, answering { "jsonapi": { "version": "1.1" }, "meta": { "result": { "total_count": 0, "total_duration_minutes": 0 } } }.
    • IPC: a #[tauri::command] named stat_get_workout, returning WorkoutStats as is.
    • TypeScript: statGetWorkout(): Promise<WorkoutStats>. The HTTP transport calls callOp<WorkoutStats>('GET', '/stats/workout'), which unwraps meta.result; the IPC transport calls invoke('stat_get_workout').
    • MCP (when the Mcp generator is configured): a tool named stat_get_workout, described by the doc comment.

Over HTTP, every function that is not served as a JSON:API resource or relationship is a custom op: the custom functions classified below, and the CRUD and junction functions of a module with no entity behind it. A custom op’s request and response are meta-only JSON:API documents. The normative rules are §10 of the wire contract.

The examples here use the hand-written task operations in crates/markdown-pilot/src/api/task.rs, the fixture the generated router is tested against (crates/markdown-pilot/tests/http_router.rs):

FunctionRouteArgumentsSuccess
get_summary(store, status: &str, verbose: Option<bool>, limit: Option<u32>) -> TaskSummaryGET /api/tasks/summary/{status}status in the path; opArg[verbose], opArg[limit]200, meta.result
list_by_status(store, status: &str) -> Vec<Task>GET /api/tasks/list-by-status/{status}status in the path200, meta.result
capture(store, input: CreateTaskInput, status: Option<String>) -> TaskPOST /api/tasks/capturemeta.args.input, meta.args.status200, meta.result
complete(store, id: &str) -> ()POST /api/tasks/completemeta.args.id204
purge_done(store) -> u64POST /api/tasks/purge-donenone; no body needed200, meta.result

A custom op answers a meta-only document with status 200. iron-log’s GET /api/stats/workout, with illustrative values (the example’s handler returns zeros):

HTTP/1.1 200 OK
Content-Type: application/vnd.api+json
Vary: Accept
{
"jsonapi": { "version": "1.1" },
"meta": { "result": { "total_count": 12, "total_duration_minutes": 540 } }
}
  • result is the function’s Ok value as serde serializes it. It is a plain value even when it is an entity or a Vec of entities: list_by_status answers an array of plain Task objects, not resource objects.
  • The status is 200, never 201, even for an op that creates something (capture). There is no data, no links and no Location.
  • A function returning () answers 204 No Content with no body (complete).

A POST carries its arguments as a meta-only document. Every argument after the state or store is a member of meta.args, keyed by its Rust parameter name — an *Input argument and Option arguments included:

POST /api/tasks/capture HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json
{ "meta": { "args": { "input": { "title": "Alpha", "status": "open", "body": "" }, "status": "inbox" } } }
{
"jsonapi": { "version": "1.1" },
"meta": { "result": { "id": "alpha", "title": "Alpha", "status": "inbox", "parent_id": null, "subtasks": [], "tags": [], "body": "" } }
}
  • A body must be sent as Content-Type: application/vnd.api+json; application/json is 415.
  • A request with no body reads as { "meta": { "args": {} } }, so a function without required arguments (purge_done) needs none. An absent or null member is None for an Option argument.
  • An unknown member of meta.args is 400 invalid_document with source.pointer /meta/args/{name}; a missing required argument points at /meta/args; a value serde rejects points at its member. Other top-level members of the document are ignored.
  • A POST op takes no query parameters at all, not even opArg[…]: POST /api/tasks/capture?opArg[status]=inbox is 400 invalid_query_parameter.

A GET has no body. Its required arguments are path segments, in declaration order, and its Option arguments are opArg[{name}] query parameters, each value read by serde from the string:

GET /api/tasks/summary/open?opArg[verbose]=true&opArg[limit]=1 HTTP/1.1
Accept: application/vnd.api+json
{ "jsonapi": { "version": "1.1" }, "meta": { "result": { "status": "open", "count": 2, "titles": ["Alpha"] } } }
  • Any other query parameter — a bare ?verbose=true, an opArg[…] the function does not declare — or a value serde rejects is 400 invalid_query_parameter, with source.parameter naming it.
  • An Option argument read from opArg must have a plain inner type (Option<bool>, Option<String>, Option<u32>). One with generic arguments (Option<Vec<String>>), a tuple, or a schema entity fails the build, and so does an *Input argument on a GET: there is no body to carry it.

A custom op is served at /api/{module}/{action}, plus one segment per required argument on a GET:

  • {module} is the kebab-cased plural of the module name (task is tasks, workout_set is workout-sets), or for a singleton module the kebab-cased name itself (auto_start is auto-start). NamingConfig::plural_overrides applies.
  • {action} is the function name without a leading get_, and without the module name as a {module}_ prefix or a _{module} / _{plural} suffix, kebab-cased: get_summary is summary, purge_done is purge-done.
  • With a route_prefix, a function that takes the store is served under the prefix: /api/projects/{project_id}/tasks/capture.
  • A method the route does not serve is 405 with an Allow header: GET /api/tasks/capture answers Allow: POST.

Each custom op becomes a Transport method named in camelCase after its IPC command (stat_get_workout is statGetWorkout), with the function’s own arguments and return type. The HTTP transport sends them through callOp(method, path, args?):

// taskGetSummary(status, verbose?, limit?): required args in the path, Option args as opArg[…]
return callOp<TaskSummary>('GET', `/tasks/summary/${encodeURIComponent(status)}${toQueryString({ opArg: { verbose, limit } })}`);
// taskCapture(input, status?): every arg in meta.args, keyed by its Rust name
return callOp<Task>('POST', '/tasks/capture', { input, status });

An argument whose Rust name has an underscore keeps it on the wire: a dry_run parameter is the method’s dryRun and the body’s dry_run.

callOp sends { meta: { args } } only when there are arguments, resolves to meta.result, and resolves to null for a 204 (a () function’s method returns Promise<null>). A non-2xx reply throws a JsonApiError carrying the HTTP status and the errors array. The IPC transport calls the same method with invoke and flat arguments, so calling code does not change between transports. See Client Generation.

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.

The CRUD rows are served as JSON:API resources when the module is backed by a schema entity; see Server Transports. In such a module the junction rows define a to-many relationship named after list_{children}, served only on its relationship endpoints (/api/workouts/{id}/relationships/{children} and /api/workouts/{id}/{children}); see Relationships. Custom operations, and the junction and CRUD functions of a module with no entity behind it, are served as custom ops: arguments go in meta.args (POST, PATCH) or opArg[name] (GET), and the result comes back under meta.result. In such a module update is a PATCH like everywhere else. See Operations that are not CRUD.

Function nameOpKindHTTPRoute example
listListGET/api/workouts
get_by_idGetByIdGET/api/workouts/{id}
createCreatePOST/api/workouts
updateUpdatePATCH/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}

The junction route examples are those of a module with no entity behind it. A list_{children}(parent_id) is a junction list only when its module also has the add_{child} or remove_{child} of the same relationship; on its own it is a CustomGet, at /api/workouts/list-{children}/{parent_id}.

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

Function shapeOpKindHTTPHow parameters are extracted
Zero user-facing parameters, name starts with a read prefixCustomGetGET(no extraction)
Zero user-facing parameters, any other nameCustomPostPOST(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 → opArg[q] query parameters
get_{name}(state, body: &CustomStruct)CustomPostPOSTBody-carrying first param → meta.args.body
Any other shapeCustomPostPOSTEvery param after the state, *Input and Option<…> included → meta.args in the body
Returns tokio::sync::broadcast::Receiver<T> (or Result<Receiver<T>, E>)EventStreamGET (SSE)/api/events/{event-name}; required params → path segments, Option<…> → query, resume ← Last-Event-ID

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 and the function name make up the route path; see Paths.

A failure from a generated route, custom ones included, is a JSON:API error document (application/vnd.api+json, an errors array with exactly one object). complete on an id that does not exist answers 404:

{
"jsonapi": { "version": "1.1" },
"errors": [ { "status": "404", "code": "task_not_found", "title": "Not Found", "detail": "Task not found: nope" } ]
}

If the function returns your AppError, the variant decides the status and code through the same suffix rule as the CRUD routes (*NotFound 404, *IdRequired 400, *AlreadyExists 409, *ParentRequired 403, otherwise 500, code the variant name in snake_case); detail is the error’s Display text. A function whose error type is not AppError answers 500 internal_error. “Your AppError” means {types_import_path}::AppError, read through the file’s use items; one reached by another path (a re-export such as crate::schema::error::AppError, or a type alias) is not recognized, answers 500 internal_error, and a type named AppError there gets a build warning. A malformed query parameter, or a method the route does not serve (405 with Allow), is also an errors document. The scan reads ServersConfig::error_source_dir. Clients branch on status and code, never on detail, whose wording may change. See Errors for every code the generated server raises itself.

The store takes no filter, so a filtered list is hand-written. Put it in the module named after the entity, and it replaces the generated list. This recipe assumes the module is paginated: it is named in .api_paginated(..) and its surface sets pagination (here PaginationConfig { default_limit: 20, max_limit: 100 }, as in the tasks-tracker example), so the list takes limit and offset and has a count beside it. This is the tasks-tracker example’s src/api/v1/task.rs, without its comments:

src/api/v1/task.rs
pub use super::generated::task::*;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use ontogen_core::order::OrderBy;
use crate::schema::{AppError, Task};
use crate::store::task::TaskSortField;
use crate::store::Store;
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct ListTasksQuery {
pub status: Option<String>,
pub epic_id: Option<String>,
}
fn matches(task: &Task, query: &ListTasksQuery) -> bool {
query.status.as_deref().is_none_or(|s| task.status == s)
&& query.epic_id.as_deref().is_none_or(|e| task.epic_id.as_deref() == Some(e))
}
/// List tasks, optionally filtered by status and epic
pub async fn list(
store: &Store,
query: ListTasksQuery,
order: &[OrderBy<TaskSortField>],
limit: Option<u64>,
offset: Option<u64>,
) -> Result<Vec<Task>, AppError> {
let tasks = store.list_tasks(order, None, None).await?;
Ok(tasks
.into_iter()
.filter(|t| matches(t, &query))
.skip(offset.unwrap_or(0) as usize)
.take(limit.map_or(usize::MAX, |l| l as usize))
.collect())
}
/// Count the tasks the same filter selects
pub async fn count(store: &Store, query: ListTasksQuery) -> Result<u64, AppError> {
let tasks = store.list_tasks(&[], None, None).await?;
Ok(tasks.iter().filter(|t| matches(t, &query)).count() as u64)
}
  • pub use super::generated::task::*; keeps get_by_id, create, update and delete available from the same module. The hand-written list and count take precedence over the generated ones of the same name.
  • The api stage must scan this directory. Pipeline scans the servers’ and clients’ api_dirs (and any api_scan_dirs you add), so src/api/v1/task.rs is found there. A direct gen_api caller lists the directory in ApiConfig::scan_dirs; otherwise the generated list is still emitted and the build fails, naming the fn as defined twice. A #[ontogen::stateless] list never replaces the generated one.
  • A paginated module needs its own count. A hand-written list drops the generated count, because the generated one counts the whole table, so the total would be wrong. Without a count that takes the same filter, the build fails.
  • Re-export the query struct from types_import_path. The generated handlers import every type an API function names from that path, so with types_import_path: "crate::schema" add pub use crate::api::v1::task::ListTasksQuery; to src/schema/mod.rs (or define the struct there).
  • The query struct is taken by value or borrowed (query: &ListTasksQuery), and at most one is allowed per list. It needs Deserialize; the MCP list tool publishes its schema, so it needs JsonSchema, and refuses arguments it cannot be read from. Taken by value, it is cloned into list so count can read it after, so it needs Clone; a borrowed struct is lent to both and needs neither. Default is not needed.
  • order opts the list into sort. It follows the filter and precedes the page. The store sorts before the filter runs, and a filter keeps the order, so the page comes out in the requested order. A list that filters in SQL passes its query through order_tasks_query(query, order) (SeaORM), and one that filters a list it already holds calls sort_tasks(&mut items, order) before the page is cut. count takes no order. Leave order out and sort on this list is 400 invalid_sort_field. The build fails when order is misplaced, repeated, or another entity’s. See Sorting.
  • Bare parameters work too: list(store, skill_id: &str, limit, offset) is filter[skill_id], required unless it is an Option. A bare parameter’s type must be one a single value can carry (&str, String, numbers, bool, unit enums). An *Input parameter on a list is refused.
  • Without an entity. A list in a module with no entity behind it is a custom op at GET /api/{module}, with filter[...] and opArg[limit]/opArg[offset], answering meta.result.

Over HTTP the list above answers:

GET /api/tasks?filter[status]=closed/done&filter[epic_id]=markdown-backend
{
"jsonapi": { "version": "1.1" },
"links": {
"self": "/api/tasks?filter%5Bepic_id%5D=markdown-backend&filter%5Bstatus%5D=closed%2Fdone&page%5Boffset%5D=0&page%5Blimit%5D=20",
"first": "/api/tasks?filter%5Bepic_id%5D=markdown-backend&filter%5Bstatus%5D=closed%2Fdone&page%5Boffset%5D=0&page%5Blimit%5D=20",
"prev": null,
"next": null,
"last": "/api/tasks?filter%5Bepic_id%5D=markdown-backend&filter%5Bstatus%5D=closed%2Fdone&page%5Boffset%5D=0&page%5Blimit%5D=20"
},
"meta": { "total": 1, "limit": 20, "offset": 0 },
"data": [ … ]
}

meta.total is count with the same filter.

In an unpaginated module the list takes no limit or offset and needs no count. It answers every matching task in data, with no meta and no pagination links; links.self carries the filter, e.g. /api/tasks?filter%5Bstatus%5D=closed%2Fdone.

An unknown member (filter[colour]), a repeated one, or a value the struct rejects is 400 invalid_query_parameter. The generated TypeScript client takes taskList(query?, limit?, offset?, options?), where options.sort holds the sort keys, and sends filter[...] and sort. See the upgrade notes for the full list of errors.

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_recent(store: &Store) -> Result<i64, AppError> { ... }

Parameters after the state/store travel in the path, as opArg[…] query parameters, or in meta.args:

// `id` becomes a path parameter: GET /api/workouts/sets/{id}
pub async fn get_sets(store: &Store, id: &str) -> Result<Vec<WorkoutSet>, AppError> { ... }
// `input` becomes `meta.args.input`: POST /api/workouts/import
pub async fn import(store: &Store, input: ImportWorkoutsInput) -> Result<Vec<Workout>, AppError> { ... }
// not `get_*`, so `id` is `meta.args.id`: POST /api/workouts/archive, answering 204
pub async fn archive(store: &Store, id: &str) -> Result<(), 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.

Two attributes override classification outright, for the cases where the name-and-shape heuristic reads a handler wrong.

#[ontogen::http::post] — for a mutating handler the classifier would route as a read. The usual case is a read-prefixed name that isn’t a read:

use ontogen::http::post;
// `get_fresh_token` starts with `get_`, so it would classify as GET.
// It mints a token — that's a mutation.
#[post]
pub async fn get_fresh_token(state: &AppState) -> Result<String, AppError> {
state.auth.mint().await
}

#[ontogen::http::get] — for a read the classifier would route as POST. This is the escape hatch for an asymmetry in the name heuristic: the read-prefix allowlist is consulted only for functions with no user-facing params, and among functions that do carry params only get_ has a branch back into CustomGet. So a read with arguments named count_*, exists_*, find_*, is_*, or has_* classifies CustomPost however side-effect-free it is:

use ontogen::http::get;
// Has a param and isn't named `get_*`, so it would classify as POST.
#[get]
pub async fn count_matching(store: &Store, pattern: &str) -> Result<i64, AppError> {
store.count_matching(pattern).await
}

Before this attribute existed the only remedy was renaming to get_*, which bends a handler’s name to satisfy the router rather than to describe what it does.

Both overrides win outright. Forcing GET is you asserting the param shape suits a GET; it deliberately does not re-run the body-carrying-first-param check that the get_* branch applies. An override that second-guesses the author isn’t an override.

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.