5. Custom endpoints
Generated command and query routes cover most of an API. For health probes, webhooks, file downloads, or integration endpoints, add custom axum routes. Every extra route must also declare how it is authorized.
Three ways to add a route
| Pattern | Declared in | Mounted by | Best for |
|---|---|---|---|
| Domain routes | domain! { routes(api_base) { … } } | mount_domain | Probes and handlers scoped to one namespace |
| OpenAPI routes | app.merge(api_router) | RiverbaseApp | Extra routes that belong in openapi.json, such as RxDB |
| Plain routes | app.register_routes(…) / app.register_public_routes(…) | RiverbaseApp | Webhooks and internal endpoints |
Domain routes
Declare routes inside domain!. The block receives api_base (usually /api) and can use
Self::NAMESPACE:
riverbase_core::domain! {
domain TodoDomain {
meta { namespace: "riverbase.todo", title: "Todo List", description: "…" }
command: TodoCommandEngine,
query: TodoQueryEngine,
routes(api_base) {
use riverbase_http::axum::{routing::get, Router};
let path = format!("{api_base}/{}/health~probe", Self::NAMESPACE);
Router::new().route(&path, get(|| async { "ok" }))
}
spawn(runtime: &DomainRuntime) { /* … as in chapter 2 … */ }
}
}
This serves GET /api/riverbase.todo/health~probe. Two requirements:
- The domain crate needs
riverbase_httpand theriverbase_corehttpfeature. Keep them behind the crate's optionalhttpfeature (see chapter 2). - The service must call
.into_arc()and thenmount_domain.into_arcregisters the routes, andmount_domainmounts them.mount_coupledignores them.
Domain routes are enforced: they go through Casbin path-to-activity mapping like generated
routes, and an unmapped path returns 403 CAS-011.
OpenAPI routes
RiverbaseApp::merge takes an aide ApiRouter, so its operations appear in openapi.json. For
example, to serve RxDB replication for a collection you implemented (see
Realtime):
use std::sync::Arc;
use riverbase_http::rxdb::RxdbRegistry;
use todo_domain::TodoRxdbCollection; // your RxdbCollection implementation
let rxdb = RxdbRegistry::new()
.with_stream_bus(runtime.stream_bus.clone())
.register(Arc::new(TodoRxdbCollection::new(dbpool)))?
.into_router();
app.mount_domain(domain).await?;
app.merge(rxdb);
Call merge after mount_domain, so the core routes register first.
Plain routes with an explicit access decision
For webhooks or internal handlers that do not belong in the API document, register a plain
axum::Router and say how it is authorized:
use riverbase_http::axum::{routing::{get, post}, Router};
use riverbase_http::web::RouteAccess;
// Authenticated and Casbin-checked like any domain route.
app.register_routes(
Router::new().route("/api/hooks/payment", post(payment_webhook)),
RouteAccess::Enforced,
);
// Public on purpose: every exemption carries a reason and a sign-off reference.
app.register_public_routes(
Router::new().route("/api/riverbase.todo/health~probe", get(|| async { "ok" })),
&["/api/riverbase.todo/health~probe"],
);
RouteAccess::Public { reason, signoff } is the explicit form of the same exemption. The older
merge_routes still compiles but is deprecated, because it skips this decision.
Register routes before serve(); the router is finalized there.
Reading the caller inside a handler
When auth is mounted, the middleware stores the resolved principal on the request:
use riverbase_http::auth::principal_from_request;
use riverbase_http::axum::{extract::Request, response::IntoResponse};
async fn whoami(req: Request) -> impl IntoResponse {
match principal_from_request(&req) {
Some(p) => format!("hello {}", p.sub),
None => "anonymous".to_string(),
}
}
Auth wiring is the subject of the next chapter.
Checklist
- Health or probe route declared in
domain!routes, or registered as public with a reason - Service calls
.into_arc()andmount_domain, not baremount_coupled - Every plain route registered with
register_routesorregister_public_routes -
curlagainst the custom path returns the expected response
Next: 6. Auth and users.