karbon_framework/http/
app.rs1use axum::{middleware, Router};
2use std::net::SocketAddr;
3use tokio::net::TcpListener;
4use tower_http::compression::CompressionLayer;
5use tower_http::cors::{Any, CorsLayer};
6use tower_http::trace::TraceLayer;
7use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
8#[cfg(feature = "studio")]
9use axum::Extension;
10
11use crate::config::Config;
12use crate::db::Database;
13use crate::mail::Mailer;
14use crate::security::RoleHierarchy;
15
16#[derive(Clone)]
18pub struct AppState {
19 pub db: Database,
20 pub config: Config,
21 pub mailer: Option<Mailer>,
22 pub role_hierarchy: RoleHierarchy,
23}
24
25pub struct App {
27 config: Config,
28 router: Option<Router<AppState>>,
29}
30
31impl App {
32 pub fn new() -> Self {
34 dotenvy::dotenv().ok();
35 let config = Config::from_env();
36 Self {
37 config,
38 router: None,
39 }
40 }
41
42 pub fn with_config(config: Config) -> Self {
44 Self {
45 config,
46 router: None,
47 }
48 }
49
50 pub fn router(mut self, router: Router<AppState>) -> Self {
52 self.router = Some(router);
53 self
54 }
55
56 fn init_tracing(&self) {
58 let filter = EnvFilter::try_from_default_env()
59 .unwrap_or_else(|_| EnvFilter::new(&self.config.log_level));
60
61 tracing_subscriber::registry()
62 .with(filter)
63 .with(tracing_subscriber::fmt::layer())
64 .init();
65 }
66
67 fn cors_layer(&self) -> CorsLayer {
69 let cors = CorsLayer::new()
70 .allow_methods(Any)
71 .allow_headers(Any);
72
73 if self.config.cors_origins.contains(&"*".to_string()) {
74 cors.allow_origin(Any)
75 } else if self.config.cors_origins.is_empty() {
76 tracing::warn!("CORS_ORIGINS is empty — no cross-origin requests will be allowed");
77 cors
78 } else {
79 let origins: Vec<_> = self
80 .config
81 .cors_origins
82 .iter()
83 .filter_map(|o| o.parse().ok())
84 .collect();
85 cors.allow_origin(origins)
86 }
87 }
88
89 pub async fn serve(self) -> anyhow::Result<()> {
91 self.init_tracing();
92
93 tracing::info!(
94 "Connecting to database at {}:{}",
95 self.config.db_host,
96 self.config.db_port
97 );
98
99 let db = Database::connect(&self.config).await?;
100 tracing::info!("Database connected");
101
102 let mailer = if !self.config.smtp_host.is_empty() && !self.config.smtp_user.is_empty() {
104 match Mailer::new(&self.config) {
105 Ok(m) => {
106 tracing::info!("Mailer initialized ({})", self.config.smtp_host);
107 Some(m)
108 }
109 Err(e) => {
110 tracing::warn!("Mailer initialization failed: {} — emails will be disabled", e);
111 None
112 }
113 }
114 } else {
115 tracing::info!("Mailer not configured — emails disabled");
116 None
117 };
118
119 let state = AppState {
120 db,
121 config: self.config.clone(),
122 mailer,
123 role_hierarchy: crate::security::default_hierarchy(),
124 };
125
126 let cors = self.cors_layer();
127 let mut router = self
128 .router
129 .unwrap_or_else(Router::new);
130
131 if cfg!(debug_assertions) {
133 tracing::info!("Debug profiler enabled");
134 router = router.layer(middleware::from_fn(crate::logger::profiler_middleware));
135 }
136
137 #[cfg(feature = "studio")]
139 let studio_enabled = cfg!(debug_assertions) || std::env::var("KARBON_STUDIO").is_ok();
140 #[cfg(feature = "studio")]
141 let studio_collector = if studio_enabled {
142 let (studio_router, collector, token) = crate::studio::build();
143 tracing::info!(
145 "\x1b[38;5;141m⚡ Studio\x1b[0m → http://{}:{}/_studio?token={}",
146 "localhost",
147 self.config.port,
148 token
149 );
150 Some((studio_router, collector))
151 } else {
152 None
153 };
154
155 if let Ok(frontend_url) = std::env::var("KARBON_FRONTEND_URL") {
157 tracing::info!("Frontend proxy enabled → {}", frontend_url);
158 let proxy = super::FrontendProxy::new(&frontend_url);
159 router = router.fallback(move |req| proxy.clone().handle(req));
160 }
161
162 #[allow(unused_mut)]
163 let mut router = router
164 .layer(middleware::from_fn(super::middleware::request_id))
165 .layer(CompressionLayer::new())
166 .layer(cors)
167 .layer(TraceLayer::new_for_http())
168 .with_state(state);
169
170 #[cfg(feature = "studio")]
171 if let Some((studio_router, collector)) = studio_collector {
172 router = router
173 .nest("/_studio", studio_router)
174 .layer(middleware::from_fn(crate::studio::middleware::studio_middleware))
175 .layer(Extension(collector));
176 }
177
178 let addr = SocketAddr::from(([0, 0, 0, 0], self.config.port));
179 tracing::info!("Server starting on http://{}", addr);
180
181 let listener = TcpListener::bind(addr).await?;
182 axum::serve(
183 listener,
184 router.into_make_service_with_connect_info::<SocketAddr>(),
185 )
186 .with_graceful_shutdown(shutdown_signal())
187 .await?;
188
189 tracing::info!("Server shut down gracefully");
190 Ok(())
191 }
192}
193
194impl Default for App {
195 fn default() -> Self {
196 Self::new()
197 }
198}
199
200async fn shutdown_signal() {
202 let ctrl_c = async {
203 tokio::signal::ctrl_c()
204 .await
205 .expect("Failed to install Ctrl+C handler");
206 };
207
208 #[cfg(unix)]
209 let terminate = async {
210 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
211 .expect("Failed to install SIGTERM handler")
212 .recv()
213 .await;
214 };
215
216 #[cfg(not(unix))]
217 let terminate = std::future::pending::<()>();
218
219 tokio::select! {
220 _ = ctrl_c => tracing::info!("Received Ctrl+C, shutting down..."),
221 _ = terminate => tracing::info!("Received SIGTERM, shutting down..."),
222 }
223}