Custom Handlers
Add raw axum routes when you need full control over HTTP handling while still inheriting Forge's middleware stack.
The Code
custom_routes takes a factory closure. Forge calls it once during run() and hands you the managed PgPool, so you can build a Router that talks to the database. The returned router is merged inside the gateway's /_api namespace, which means it automatically picks up auth, CORS, tracing, concurrency limits, and timeouts.
use std::sync::Arc;
use forge::prelude::*;
use forge::prelude::axum::{
Router,
Extension,
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
};
async fn csv_export(
State(pool): State<Arc<sqlx::PgPool>>,
Extension(auth): Extension<AuthContext>,
Query(params): Query<ExportParams>,
) -> impl IntoResponse {
let user_id = match auth.user_id() {
Some(id) => id,
None => return (StatusCode::UNAUTHORIZED, "Authentication required").into_response(),
};
// ... query `pool`, stream CSV for `user_id`, etc.
(StatusCode::OK, "ok").into_response()
}
#[tokio::main]
async fn main() -> Result<()> {
let config = ForgeConfig::from_file("forge.toml")?;
Forge::builder()
.config(config)
.custom_routes(|pool| {
Router::new()
.route("/export/csv", get(csv_export))
.with_state(Arc::new(pool))
})
.build()?
.run()
.await
}
Route paths are relative to /_api. The GET /export/csv handler above is reachable at GET /_api/export/csv.
If your handlers don't need the pool, ignore the argument:
Forge::builder()
.config(config)
.custom_routes(|_| Router::new().route("/healthz", get(|| async { "ok" })))
.build()?
.run()
.await
What Happens
The factory runs after the pool is connected and before the gateway starts. Forge merges your router into the gateway's main router, then wraps the combined router with the middleware stack, so custom handlers get the same auth, tracing, and rate limiting as built-in endpoints. Use Extension<AuthContext> to read the authenticated user without parsing JWTs by hand.
When to Use
| Scenario | Use |
|---|---|
| Standard queries and mutations | #[forge::query], #[forge::mutation] |
| Webhook endpoints with signatures | #[forge::webhook] |
| AI tool exposure | #[forge::mcp_tool] |
| Streaming responses, file downloads, custom content types | custom_routes |
| Third-party integrations needing exact HTTP semantics | custom_routes |
Route Conflicts
Custom routes share the /_api namespace with built-in Forge endpoints. Registering a path that already belongs to Forge panics at startup. Avoid:
/health,/ready/rpc,/rpc/batch,/rpc/{function},/rpc/{function}/upload/events,/subscribe,/unsubscribe,/subscribe-job,/subscribe-workflow/signal/event,/signal/view,/signal/user,/signal/report/webhooks/*/mcp(if MCP is enabled)/oauth/authorize,/oauth/token,/oauth/register(if MCP OAuth is enabled)
Authentication
The auth middleware runs before your handler. Unauthenticated requests still reach the handler with an unauthenticated AuthContext, so check before trusting it:
async fn my_handler(
Extension(auth): Extension<AuthContext>,
) -> impl IntoResponse {
let user_id = match auth.user_id() {
Some(id) => id,
None => return (StatusCode::UNAUTHORIZED, "Not authenticated").into_response(),
};
// user_id available for authorization checks
(StatusCode::OK, "ok").into_response()
}