Chuyển tới nội dung chính

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​

PatternDeclared inMounted byBest for
Domain routesdomain! { routes(api_base) { … } }mount_domainProbes and handlers scoped to one namespace
OpenAPI routesapp.merge(api_router)RiverbaseAppExtra routes that belong in openapi.json, such as RxDB
Plain routesapp.register_routes(…) / app.register_public_routes(…)RiverbaseAppWebhooks and internal endpoints

Domain routes​

Declare routes inside domain!. The block receives api_base (usually /api) and can use Self::NAMESPACE:

domain/src/domain.rs
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_http and the riverbase_core http feature. Keep them behind the crate's optional http feature (see chapter 2).
  • The service must call .into_arc() and then mount_domain. into_arc registers the routes, and mount_domain mounts them. mount_coupled ignores 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):

service/src/main.rs
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:

service/src/main.rs
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() and mount_domain, not bare mount_coupled
  • Every plain route registered with register_routes or register_public_routes
  • curl against the custom path returns the expected response

Next: 6. Auth and users.