pub mod admin;
pub mod data;
pub mod openapi;
pub mod response_helpers;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
use serde_json::json;
use utoipa::OpenApi;
use crate::server::state::AppState;
#[derive(Debug, Clone, Copy)]
pub struct RouteOptions {
pub max_admin_body_size: usize,
pub docs_enabled: bool,
pub metrics_enabled: bool,
}
pub fn api_routes(options: RouteOptions) -> Router<AppState> {
let RouteOptions {
max_admin_body_size,
docs_enabled,
metrics_enabled,
} = options;
let router = Router::new()
.route("/health", get(health_check))
.route("/healthz", get(liveness_check))
.route("/readyz", get(readiness_check))
.nest("/api/v1/admin", admin::admin_routes(max_admin_body_size))
.nest("/api/v1/data", data::data_routes());
let router = if metrics_enabled {
router.route("/metrics", get(metrics_endpoint))
} else {
router
};
let router = if docs_enabled {
router.merge(
utoipa_swagger_ui::SwaggerUi::new("/docs")
.url("/api/v1/openapi.json", openapi::ApiDoc::openapi()),
)
} else {
router
};
router
.fallback(|| async {
crate::errors::OrionError::NotFound("No route matches this path".to_string())
})
.method_not_allowed_fallback(|| async {
crate::errors::OrionError::MethodNotAllowed(
"The HTTP method is not allowed for this path".to_string(),
)
})
}
#[utoipa::path(
get,
path = "/health",
tag = "Operational",
description = "\
Detailed health report. Always reachable, but when `admin_auth.enabled` is \
true the topology detail (`git_hash`, `build_timestamp`, `workflows_loaded`, \
the circuit-breaker map, connector load failures and quarantined channels — \
names and failure reasons) is included only for requests presenting a valid \
admin credential; anonymous callers get status, version, uptime and coarse \
per-component states. Probes should use `/healthz` and `/readyz`.",
responses(
(status = 200, description = "Service healthy", body = crate::server::routes::openapi::HealthStatus),
(status = 503, description = "Service degraded"),
)
)]
#[tracing::instrument(skip(state, headers))]
pub(crate) async fn health_check(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let uptime = chrono::Utc::now() - state.start_time;
let db_healthy = state.ping_db().await.is_ok();
let workflows_loaded = workflows_loaded(&state);
let cb_states = state.connector_registry.circuit_breaker_states().await;
let connector_issues = state.connector_registry.load_issues().await;
let quarantined_channels = state.channel_registry.quarantined();
let kafka_state = kafka_component(&state);
let overall_healthy = db_healthy;
let fully_loaded = connector_issues.is_empty()
&& quarantined_channels.is_empty()
&& kafka_state != Some("error");
let status_str = if overall_healthy && fully_loaded {
"ok"
} else {
"degraded"
};
let http_status = if overall_healthy {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
let auth_cfg = &state.config.admin_auth;
let show_detail = !auth_cfg.enabled
|| crate::server::admin_auth::headers_present_valid_key(&headers, auth_cfg);
let mut body = json!({
"status": status_str,
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": uptime.num_seconds(),
"components": {
"database": if db_healthy { "ok" } else { "error" },
"engine": "ok",
"connectors": if connector_issues.is_empty() { "ok" } else { "degraded" },
"channels": if quarantined_channels.is_empty() { "ok" } else { "degraded" },
},
});
if let Some(kafka) = kafka_state {
body["components"]["kafka"] = json!(kafka);
}
if show_detail {
body["git_hash"] = json!(env!("GIT_HASH"));
body["build_timestamp"] = json!(env!("BUILD_TIMESTAMP"));
body["workflows_loaded"] = json!(workflows_loaded);
body["connectors"] = json!({
"circuit_breaker_scope": "node",
"circuit_breakers": cb_states,
"failed_to_load": connector_issues,
});
body["channels"] = json!({
"quarantined": quarantined_channels,
});
}
(http_status, Json(body))
}
#[utoipa::path(
get,
path = "/metrics",
tag = "Operational",
description = "\
Prometheus exposition endpoint. Registered only when `metrics.enabled` is \
true — otherwise the path 404s, so a deployment with metrics off is not \
mistaken for a working scrape target.
On this listener it is guarded by the same admin credential as \
`/api/v1/admin/*` when `admin_auth.enabled` is true, so scrapers must be \
configured with the key. Setting `metrics.bind_addr` instead moves the \
endpoint to a dedicated unauthenticated listener on a private interface and \
removes it from this one entirely.",
responses(
(status = 200, description = "Prometheus metrics", content_type = "text/plain"),
)
)]
pub(crate) async fn metrics_endpoint(State(state): State<AppState>) -> impl IntoResponse {
let (pool_size, pool_idle) = state.pool_stats();
crate::metrics::set_db_pool_size(pool_size as f64);
crate::metrics::set_db_pool_idle(pool_idle as f64);
let metrics = state.metrics_handle.render();
(
StatusCode::OK,
[("content-type", "text/plain; version=0.0.4; charset=utf-8")],
metrics,
)
}
#[utoipa::path(
get,
path = "/healthz",
tag = "Operational",
operation_id = "liveness_probe",
summary = "Liveness probe",
description = "\
Liveness probe. Returns `200 {\"status\":\"ok\"}` as long as the process is \
running and the HTTP server is accepting connections — it performs no \
dependency checks, so a database or Redis outage must not restart the pod. \
Use `/readyz` for rotation decisions and `/health` for a detailed report. \
Unauthenticated, so probes work without provisioning an admin key.",
responses(
(status = 200, description = "Process is alive", body = crate::server::routes::openapi::HealthStatus),
)
)]
pub(crate) async fn liveness_check() -> impl IntoResponse {
(StatusCode::OK, Json(json!({ "status": "ok" })))
}
async fn cluster_redis_healthy(state: &AppState) -> Option<bool> {
let mut conn = state.cluster.redis.clone()?;
let ping = async move {
let pong: redis::RedisResult<String> = redis::cmd("PING").query_async(&mut conn).await;
match pong {
Ok(_) => true,
Err(e) => {
tracing::warn!(error = %e, "Cluster Redis ping failed; reporting not ready");
false
}
}
};
Some(
tokio::time::timeout(
std::time::Duration::from_secs(state.config.engine.health_check_timeout_secs),
ping,
)
.await
.unwrap_or(false),
)
}
fn workflows_loaded(state: &AppState) -> usize {
state.engine.load().workflows().len()
}
fn kafka_component(state: &AppState) -> Option<&'static str> {
if !state.config.kafka.enabled {
return None;
}
if state.kafka.ingest_status.is_degraded() {
return Some("error");
}
let consumer_dead = match state.kafka.consumer_handle.try_lock() {
Ok(guard) => guard.as_ref().is_some_and(|h| h.is_finished()),
Err(_) => false,
};
Some(if consumer_dead { "error" } else { "ok" })
}
#[utoipa::path(
get,
path = "/readyz",
tag = "Operational",
operation_id = "readiness_probe",
summary = "Readiness probe",
description = "\
Readiness probe. Reports `ready` only when the database responds, startup \
has completed, — in cluster mode — the shared Redis answers `PING`, and — \
with Kafka enabled — the ingest consumer is not degraded. The \
`components.engine` field is a constant `\"ok\"` kept for response-shape \
stability: the engine snapshot is lock-free and cannot be unavailable once \
the process serves. Both conditional checks matter because those degradations are \
otherwise silent: without Redis, deduplication fails open, the shared \
response cache misses, and cluster rate limiting stops enforcing; with the \
consumer down, no message is ingested — all while the data plane keeps \
returning 200s.
The `components.cluster_redis` field is present only in cluster mode, and \
`components.kafka` only when `kafka.enabled` is true. Unauthenticated, so \
probes work without provisioning an admin key.",
responses(
(status = 200, description = "All components ready", body = crate::server::routes::openapi::HealthStatus),
(status = 503, description = "At least one component is not ready — same body shape with `\"status\":\"not_ready\"`"),
)
)]
pub(crate) async fn readiness_check(State(state): State<AppState>) -> impl IntoResponse {
use std::sync::atomic::Ordering;
let initialized = state.ready.load(Ordering::Acquire);
let (db_ping, redis_healthy) = tokio::join!(state.ping_db(), cluster_redis_healthy(&state));
let db_healthy = db_ping.is_ok();
let kafka_state = kafka_component(&state);
let all_ready =
db_healthy && initialized && redis_healthy.unwrap_or(true) && kafka_state != Some("error");
let http_status = if all_ready {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
let mut components = json!({
"database": if db_healthy { "ok" } else { "error" },
"engine": "ok",
"initialized": initialized,
});
if let Some(healthy) = redis_healthy {
components["cluster_redis"] = json!(if healthy { "ok" } else { "error" });
}
if let Some(kafka) = kafka_state {
components["kafka"] = json!(kafka);
}
let body = json!({
"status": if all_ready { "ready" } else { "not_ready" },
"components": components,
});
(http_status, Json(body))
}