use axum::{
extract::DefaultBodyLimit,
http::{header, StatusCode},
response::{Html, IntoResponse, Json},
routing::{get, post},
Router,
};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::data::cdot::CdotMapper;
use crate::liftover::Liftover;
use crate::service::{
config::ServiceConfig,
handlers,
tools::ToolManager,
types::{DetailedHealthResponse, ErrorResponse, ServiceError},
};
#[derive(Clone)]
pub struct HealthCache {
pub detailed: Arc<RwLock<Option<DetailedHealthResponse>>>,
pub last_updated: Arc<RwLock<Option<chrono::DateTime<chrono::Utc>>>>,
}
impl Default for HealthCache {
fn default() -> Self {
Self {
detailed: Arc::new(RwLock::new(None)),
last_updated: Arc::new(RwLock::new(None)),
}
}
}
#[derive(Clone)]
pub struct AppState {
pub tool_manager: Arc<ToolManager>,
pub config: Arc<ServiceConfig>,
pub health_cache: HealthCache,
pub cdot: Option<Arc<CdotMapper>>,
pub liftover: Option<Arc<Liftover>>,
}
pub fn create_app(config: ServiceConfig) -> Result<(Router, AppState), ServiceError> {
let tool_manager = Arc::new(ToolManager::new(&config)?);
let cdot = if let Some(cdot_path) = &config.data.cdot_path {
if cdot_path.exists() {
tracing::info!("Loading cdot transcript data from {}", cdot_path.display());
match load_cdot(cdot_path) {
Ok(mapper) => {
tracing::info!("Loaded {} transcripts from cdot", mapper.transcript_count());
Some(Arc::new(mapper))
}
Err(e) => {
tracing::warn!(
"Failed to load cdot data: {}. Coordinate conversion will be limited.",
e
);
None
}
}
} else {
tracing::warn!(
"Cdot path {} does not exist. Coordinate conversion will be limited.",
cdot_path.display()
);
None
}
} else {
tracing::debug!("No cdot path configured. Coordinate conversion will be limited.");
None
};
let liftover = if let Some(liftover_config) = &config.data.liftover {
if liftover_config.grch37_to_38.exists() && liftover_config.grch38_to_37.exists() {
tracing::info!("Loading liftover chain files");
match Liftover::from_files(&liftover_config.grch37_to_38, &liftover_config.grch38_to_37)
{
Ok(lo) => {
tracing::info!("Liftover chain files loaded successfully");
Some(Arc::new(lo))
}
Err(e) => {
tracing::warn!(
"Failed to load liftover chain files: {}. Liftover will be unavailable.",
e
);
None
}
}
} else {
tracing::warn!("Liftover chain files not found. Liftover will be unavailable.");
None
}
} else {
tracing::debug!("No liftover chain files configured. Liftover will be unavailable.");
None
};
let state = AppState {
tool_manager,
config: Arc::new(config.clone()),
health_cache: HealthCache::default(),
cdot,
liftover,
};
let max_size = parse_size(&config.server.max_request_size)
.map_err(|e| ServiceError::ConfigError(format!("Invalid max_request_size: {}", e)))?;
let mut app = Router::new()
.route("/", get(index_handler))
.route("/static/css/styles.css", get(styles_css_handler))
.route("/static/js/main.js", get(main_js_handler))
.route("/health", get(handlers::health::health_check))
.route(
"/health/detailed",
get(handlers::health::detailed_health_check),
)
.route("/api/v1/health", get(handlers::health::health_check))
.route(
"/api/v1/health/detailed",
get(handlers::health::detailed_health_check),
)
.route("/api/v1/tools/status", get(handlers::health::tools_status))
.route("/api/v1/parse", post(handlers::parse::parse_single))
.route(
"/api/v1/validate",
post(handlers::validate::validate_single),
)
.route(
"/api/v1/normalize",
post(handlers::normalize::normalize_single),
)
.route("/api/v1/batch/parse", post(handlers::parse::parse_batch))
.route(
"/api/v1/batch/normalize",
post(handlers::normalize::normalize_batch),
)
.route("/api/v1/convert", post(handlers::convert::convert))
.route("/api/v1/effect", post(handlers::effect::predict_effect))
.route("/api/v1/liftover", post(handlers::liftover::liftover))
.route(
"/api/v1/vcf-to-hgvs",
post(handlers::vcf_convert::vcf_to_hgvs),
)
.route(
"/api/v1/hgvs-to-vcf",
post(handlers::vcf_convert::hgvs_to_vcf),
)
.route("/api/v1/info", get(handlers::info::service_info))
.fallback(handle_404)
.with_state(state.clone());
app = app.layer(DefaultBodyLimit::max(max_size));
Ok((app, state))
}
async fn handle_404() -> (StatusCode, Json<ErrorResponse>) {
let error = ServiceError::BadRequest("Endpoint not found".to_string());
(StatusCode::NOT_FOUND, Json(error.to_response()))
}
async fn index_handler() -> Html<&'static str> {
Html(include_str!("web/templates/index.html"))
}
async fn styles_css_handler() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
include_str!("web/static/css/styles.css"),
)
}
async fn main_js_handler() -> impl IntoResponse {
(
[(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)],
include_str!("web/static/js/main.js"),
)
}
fn parse_size(size_str: &str) -> Result<usize, String> {
let size_str = size_str.to_uppercase();
if let Some(num_str) = size_str.strip_suffix("GB") {
let num: usize = num_str
.parse()
.map_err(|_| format!("Invalid size format: {}", size_str))?;
return Ok(num * 1024 * 1024 * 1024);
}
if let Some(num_str) = size_str.strip_suffix("MB") {
let num: usize = num_str
.parse()
.map_err(|_| format!("Invalid size format: {}", size_str))?;
return Ok(num * 1024 * 1024);
}
if let Some(num_str) = size_str.strip_suffix("KB") {
let num: usize = num_str
.parse()
.map_err(|_| format!("Invalid size format: {}", size_str))?;
return Ok(num * 1024);
}
if let Some(num_str) = size_str.strip_suffix("B") {
return num_str
.parse::<usize>()
.map_err(|_| format!("Invalid size format: {}", size_str));
}
size_str
.parse::<usize>()
.map_err(|_| format!("Invalid size format: {}", size_str))
}
const HEALTH_CHECK_INTERVAL_SECS: u64 = 15 * 60;
pub fn spawn_health_check_task(state: AppState) {
tokio::spawn(async move {
update_health_cache(&state).await;
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(HEALTH_CHECK_INTERVAL_SECS));
interval.tick().await;
loop {
interval.tick().await;
tracing::info!("Running periodic health check...");
update_health_cache(&state).await;
}
});
}
async fn update_health_cache(state: &AppState) {
match handlers::health::run_detailed_health_check(state).await {
Ok(response) => {
let now = chrono::Utc::now();
*state.health_cache.detailed.write().await = Some(response);
*state.health_cache.last_updated.write().await = Some(now);
tracing::info!("Health cache updated at {}", now);
}
Err(e) => {
tracing::error!("Failed to update health cache: {:?}", e);
}
}
}
fn load_cdot(path: &std::path::Path) -> Result<CdotMapper, ServiceError> {
CdotMapper::load(path)
.map_err(|e| ServiceError::ConfigError(format!("Failed to load cdot: {}", e)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_size() {
assert_eq!(parse_size("100").unwrap(), 100);
assert_eq!(parse_size("100B").unwrap(), 100);
assert_eq!(parse_size("1KB").unwrap(), 1024);
assert_eq!(parse_size("10MB").unwrap(), 10 * 1024 * 1024);
assert_eq!(parse_size("1GB").unwrap(), 1024 * 1024 * 1024);
assert_eq!(parse_size("10mb").unwrap(), 10 * 1024 * 1024);
assert!(parse_size("invalid").is_err());
assert!(parse_size("10XB").is_err());
}
}