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 #[cfg(feature = "templates")]
24 pub templates: crate::template::TemplateEngine,
25}
26
27pub struct App {
29 config: Config,
30 router: Option<Router<AppState>>,
31}
32
33impl App {
34 pub fn new() -> Self {
36 dotenvy::dotenv().ok();
37 let config = Config::from_env();
38 Self {
39 config,
40 router: None,
41 }
42 }
43
44 pub fn with_config(config: Config) -> Self {
46 Self {
47 config,
48 router: None,
49 }
50 }
51
52 pub fn router(mut self, router: Router<AppState>) -> Self {
54 self.router = Some(router);
55 self
56 }
57
58 fn init_tracing(&self) {
60 let filter = EnvFilter::try_from_default_env()
61 .unwrap_or_else(|_| EnvFilter::new(&self.config.log_level));
62
63 tracing_subscriber::registry()
64 .with(filter)
65 .with(tracing_subscriber::fmt::layer())
66 .init();
67 }
68
69 fn cors_layer(&self) -> CorsLayer {
71 let cors = CorsLayer::new()
72 .allow_methods(Any)
73 .allow_headers(Any);
74
75 if self.config.cors_origins.contains(&"*".to_string()) {
76 cors.allow_origin(Any)
77 } else if self.config.cors_origins.is_empty() {
78 tracing::warn!("CORS_ORIGINS is empty — no cross-origin requests will be allowed");
79 cors
80 } else {
81 let origins: Vec<_> = self
82 .config
83 .cors_origins
84 .iter()
85 .filter_map(|o| o.parse().ok())
86 .collect();
87 cors.allow_origin(origins)
88 }
89 }
90
91 pub async fn serve(self) -> anyhow::Result<()> {
93 self.init_tracing();
94
95 tracing::info!(
96 "Connecting to database at {}:{}",
97 self.config.db_host,
98 self.config.db_port
99 );
100
101 let db = Database::connect(&self.config).await?;
102 tracing::info!("Database connected");
103
104 let mailer = if !self.config.smtp_host.is_empty() && !self.config.smtp_user.is_empty() {
106 match Mailer::new(&self.config) {
107 Ok(m) => {
108 tracing::info!("Mailer initialized ({})", self.config.smtp_host);
109 Some(m)
110 }
111 Err(e) => {
112 tracing::warn!("Mailer initialization failed: {} — emails will be disabled", e);
113 None
114 }
115 }
116 } else {
117 tracing::info!("Mailer not configured — emails disabled");
118 None
119 };
120
121 #[cfg(feature = "templates")]
123 let templates = {
124 let tpl_dir = std::env::var("TEMPLATE_DIR").unwrap_or_else(|_| "./templates".to_string());
125 if std::path::Path::new(&tpl_dir).exists() {
126 match crate::template::TemplateEngine::new(&tpl_dir) {
127 Ok(engine) => engine,
128 Err(e) => {
129 tracing::warn!("Template engine init failed: {} — using empty engine", e);
130 crate::template::TemplateEngine::empty()
131 }
132 }
133 } else {
134 tracing::debug!("No template directory at '{}', using empty engine", tpl_dir);
135 crate::template::TemplateEngine::empty()
136 }
137 };
138
139 let state = AppState {
140 db,
141 config: self.config.clone(),
142 mailer,
143 role_hierarchy: crate::security::default_hierarchy(),
144 #[cfg(feature = "templates")]
145 templates,
146 };
147
148 let cors = self.cors_layer();
149 let mut router = self
150 .router
151 .unwrap_or_else(Router::new);
152
153 if cfg!(debug_assertions) {
155 tracing::info!("Debug profiler enabled");
156 router = router.layer(middleware::from_fn(crate::logger::profiler_middleware));
157 }
158
159 #[cfg(feature = "studio")]
161 let studio_enabled = cfg!(debug_assertions) || std::env::var("KARBON_STUDIO").is_ok();
162 #[cfg(feature = "studio")]
163 let studio_collector = if studio_enabled {
164 let (studio_router, collector, token) = crate::studio::build();
165 tracing::info!(
167 "\x1b[38;5;141m⚡ Studio\x1b[0m → http://{}:{}/_studio?token={}",
168 "localhost",
169 self.config.port,
170 token
171 );
172 Some((studio_router, collector))
173 } else {
174 None
175 };
176
177 if let Ok(frontend_url) = std::env::var("KARBON_FRONTEND_URL") {
179 tracing::info!("Frontend proxy enabled → {}", frontend_url);
180 let proxy = super::FrontendProxy::new(&frontend_url);
181 router = router.fallback(move |req| proxy.clone().handle(req));
182 }
183
184 #[allow(unused_mut)]
185 let mut router = router
186 .layer(middleware::from_fn(super::middleware::request_id))
187 .layer(CompressionLayer::new())
188 .layer(cors)
189 .layer(TraceLayer::new_for_http())
190 .with_state(state);
191
192 #[cfg(feature = "studio")]
193 if let Some((studio_router, collector)) = studio_collector {
194 router = router
195 .nest("/_studio", studio_router)
196 .layer(middleware::from_fn(crate::studio::middleware::studio_middleware))
197 .layer(Extension(collector));
198 }
199
200 let addr = SocketAddr::from(([0, 0, 0, 0], self.config.port));
201 tracing::info!("Server starting on http://{}", addr);
202
203 let listener = TcpListener::bind(addr).await?;
204 axum::serve(
205 listener,
206 router.into_make_service_with_connect_info::<SocketAddr>(),
207 )
208 .with_graceful_shutdown(shutdown_signal())
209 .await?;
210
211 tracing::info!("Server shut down gracefully");
212 Ok(())
213 }
214}
215
216impl Default for App {
217 fn default() -> Self {
218 Self::new()
219 }
220}
221
222async fn shutdown_signal() {
224 let ctrl_c = async {
225 tokio::signal::ctrl_c()
226 .await
227 .expect("Failed to install Ctrl+C handler");
228 };
229
230 #[cfg(unix)]
231 let terminate = async {
232 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
233 .expect("Failed to install SIGTERM handler")
234 .recv()
235 .await;
236 };
237
238 #[cfg(not(unix))]
239 let terminate = std::future::pending::<()>();
240
241 tokio::select! {
242 _ = ctrl_c => tracing::info!("Received Ctrl+C, shutting down..."),
243 _ = terminate => tracing::info!("Received SIGTERM, shutting down..."),
244 }
245}