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.
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”The iron-log example has one custom endpoint, a workout stats summary. This recipe walks through it.
-
Create the module file
Section titled “Create the module file”Put your custom endpoint in the API directory alongside the
generated/subdirectory. iron-log’s generated CRUD lives undersrc/api/v1/generated/, and the stats endpoint issrc/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
AppStatetype or yourStoretype (as configured inApiConfig). 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_workouttakes no arguments after the store, and a function like that is aGETonly when its name starts with a read prefix such asget_; otherwise it is aPOST. See Function classification rules. - Doc comments: the
///comments become thedocfield inApiFnMeta. MCP tools use this as the tool description. Write useful descriptions.
WorkoutStatslives incrate::schema, thetypes_import_path, because the generated handlers import every type an API function names from there. - 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 activity;pub mod generated;pub mod stats; // your custom modulepub use generated::*; -
Configure scan directories
Section titled “Configure scan directories”The
Pipelinebuilder scans the servers’ and clients’api_dir(src/api/v1in iron-log), so with it there is nothing to add;.api_scan_dirs(..)adds more directories. A directgen_apicaller lists the directory inApiConfig::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 modulesstate_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
.rsfile insrc/api/v1/(excluding thegenerated/subdirectory) and extracts function signatures. -
Terminal window cargo buildThe scanner parses
stats.rs, extractsget_workout, classifies it asCustomGet, and includes it inApiOutput. Downstream generators produce handlers for it. -
Check the generated transports
Section titled “Check the generated transports”After the build,
get_workoutappears in every generated transport beside the CRUD handlers:- HTTP:
GET /api/stats/workout, served bystat_get_workout, answering{ "jsonapi": { "version": "1.1" }, "meta": { "result": { "total_count": 0, "total_duration_minutes": 0 } } }. - IPC: a
#[tauri::command]namedstat_get_workout, returningWorkoutStatsas is. - TypeScript:
statGetWorkout(): Promise<WorkoutStats>. The HTTP transport callscallOp<WorkoutStats>('GET', '/stats/workout'), which unwrapsmeta.result; the IPC transport callsinvoke('stat_get_workout'). - MCP (when the
Mcpgenerator is configured): a tool namedstat_get_workout, described by the doc comment.
- HTTP:
Custom ops on the wire
Section titled “Custom ops on the wire”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):
| Function | Route | Arguments | Success |
|---|---|---|---|
get_summary(store, status: &str, verbose: Option<bool>, limit: Option<u32>) -> TaskSummary | GET /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 path | 200, meta.result |
capture(store, input: CreateTaskInput, status: Option<String>) -> Task | POST /api/tasks/capture | meta.args.input, meta.args.status | 200, meta.result |
complete(store, id: &str) -> () | POST /api/tasks/complete | meta.args.id | 204 |
purge_done(store) -> u64 | POST /api/tasks/purge-done | none; no body needed | 200, meta.result |
Responses
Section titled “Responses”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 OKContent-Type: application/vnd.api+jsonVary: Accept
{ "jsonapi": { "version": "1.1" }, "meta": { "result": { "total_count": 12, "total_duration_minutes": 540 } }}resultis the function’sOkvalue as serde serializes it. It is a plain value even when it is an entity or aVecof entities:list_by_statusanswers an array of plainTaskobjects, not resource objects.- The status is
200, never201, even for an op that creates something (capture). There is nodata, nolinksand noLocation. - A function returning
()answers204 No Contentwith no body (complete).
POST bodies
Section titled “POST bodies”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.1Content-Type: application/vnd.api+jsonAccept: 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/jsonis415. - A request with no body reads as
{ "meta": { "args": {} } }, so a function without required arguments (purge_done) needs none. An absent ornullmember isNonefor anOptionargument. - An unknown member of
meta.argsis400 invalid_documentwithsource.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
POSTop takes no query parameters at all, not evenopArg[…]:POST /api/tasks/capture?opArg[status]=inboxis400 invalid_query_parameter.
GET arguments
Section titled “GET arguments”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.1Accept: application/vnd.api+json{ "jsonapi": { "version": "1.1" }, "meta": { "result": { "status": "open", "count": 2, "titles": ["Alpha"] } } }- Any other query parameter — a bare
?verbose=true, anopArg[…]the function does not declare — or a value serde rejects is400 invalid_query_parameter, withsource.parameternaming it. - An
Optionargument read fromopArgmust 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*Inputargument on aGET: 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 (taskistasks,workout_setisworkout-sets), or for a singleton module the kebab-cased name itself (auto_startisauto-start).NamingConfig::plural_overridesapplies.{action}is the function name without a leadingget_, and without the module name as a{module}_prefix or a_{module}/_{plural}suffix, kebab-cased:get_summaryissummary,purge_doneispurge-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
405with anAllowheader:GET /api/tasks/captureanswersAllow: POST.
The TypeScript client
Section titled “The TypeScript client”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 namereturn 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.
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”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 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 | PATCH | /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} |
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}.
Custom functions (CustomGet / CustomPost)
Section titled “Custom functions (CustomGet / CustomPost)”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 shape | OpKind | HTTP | How parameters are extracted |
|---|---|---|---|
| Zero user-facing parameters, name starts with a read prefix | CustomGet | GET | (no extraction) |
| Zero user-facing parameters, any other name | CustomPost | POST | (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 → opArg[q] query parameters |
get_{name}(state, body: &CustomStruct) | CustomPost | POST | Body-carrying first param → meta.args.body |
| Any other shape | CustomPost | POST | Every param after the state, *Input and Option<…> included → meta.args in the body |
Returns tokio::sync::broadcast::Receiver<T> (or Result<Receiver<T>, E>) | EventStream | GET (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.
Errors from custom endpoints
Section titled “Errors from custom endpoints”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.
Recipe: Filtered lists
Section titled “Recipe: Filtered lists”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:
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 epicpub 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 selectspub 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::*;keepsget_by_id,create,updateanddeleteavailable from the same module. The hand-writtenlistandcounttake precedence over the generated ones of the same name.- The api stage must scan this directory.
Pipelinescans the servers’ and clients’api_dirs (and anyapi_scan_dirsyou add), sosrc/api/v1/task.rsis found there. A directgen_apicaller lists the directory inApiConfig::scan_dirs; otherwise the generatedlistis still emitted and the build fails, naming the fn as defined twice. A#[ontogen::stateless]listnever replaces the generated one. - A paginated module needs its own
count. A hand-writtenlistdrops the generatedcount, because the generated one counts the whole table, so the total would be wrong. Without acountthat 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 withtypes_import_path: "crate::schema"addpub use crate::api::v1::task::ListTasksQuery;tosrc/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 needsDeserialize; the MCP list tool publishes its schema, so it needsJsonSchema, and refuses arguments it cannot be read from. Taken by value, it is cloned intolistsocountcan read it after, so it needsClone; a borrowed struct is lent to both and needs neither.Defaultis not needed. orderopts the list intosort. 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 throughorder_tasks_query(query, order)(SeaORM), and one that filters a list it already holds callssort_tasks(&mut items, order)before the page is cut.counttakes no order. Leaveorderout andsorton this list is400 invalid_sort_field. The build fails whenorderis misplaced, repeated, or another entity’s. See Sorting.- Bare parameters work too:
list(store, skill_id: &str, limit, offset)isfilter[skill_id], required unless it is anOption. A bare parameter’s type must be one a single value can carry (&str,String, numbers,bool, unit enums). An*Inputparameter on alistis refused. - Without an entity. A
listin a module with no entity behind it is a custom op atGET /api/{module}, withfilter[...]andopArg[limit]/opArg[offset], answeringmeta.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.
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_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/importpub async fn import(store: &Store, input: ImportWorkoutsInput) -> Result<Vec<Workout>, AppError> { ... }
// not `get_*`, so `id` is `meta.args.id`: POST /api/workouts/archive, answering 204pub 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.
Forcing a method
Section titled “Forcing a method”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.
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.