use axum::{ body::Body, extract::{Request, State}, http::{HeaderValue, Method, StatusCode}, response::IntoResponse, routing::any, Router, }; use std::net::SocketAddr; use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[derive(Clone)] struct Services { users_url: String, companies_url: String, job_seekers_url: String, customers_url: String, // ── 9 separate profession services ──────────────────────────────────── photographers_url: String, makeup_artists_url: String, tutors_url: String, developers_url: String, video_editors_url: String, graphic_designers_url: String, social_media_managers_url: String, fitness_trainers_url: String, catering_services_url: String, ugc_content_creators_url: String, // ── Payments ───────────────────────────────────────────────────────── payments_url: String, // ── Employees (Internal) ───────────────────────────────────────────── employees_url: String, client: reqwest::Client, } impl Services { fn from_env() -> Self { Self { users_url: std::env::var("USERS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9101".to_string()), companies_url: std::env::var("COMPANIES_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9102".to_string()), job_seekers_url: std::env::var("JOB_SEEKERS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9104".to_string()), customers_url: std::env::var("CUSTOMERS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9105".to_string()), photographers_url: std::env::var("PHOTOGRAPHERS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9107".to_string()), makeup_artists_url: std::env::var("MAKEUP_ARTISTS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9109".to_string()), tutors_url: std::env::var("TUTORS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9108".to_string()), developers_url: std::env::var("DEVELOPERS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9110".to_string()), video_editors_url: std::env::var("VIDEO_EDITORS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9111".to_string()), graphic_designers_url: std::env::var("GRAPHIC_DESIGNERS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9112".to_string()), social_media_managers_url: std::env::var("SOCIAL_MEDIA_MANAGERS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9113".to_string()), fitness_trainers_url: std::env::var("FITNESS_TRAINERS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9114".to_string()), catering_services_url: std::env::var("CATERING_SERVICES_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9115".to_string()), ugc_content_creators_url: std::env::var("UGC_CONTENT_CREATORS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9117".to_string()), payments_url: std::env::var("PAYMENTS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9116".to_string()), employees_url: std::env::var("EMPLOYEES_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9106".to_string()), client: reqwest::Client::new(), } } fn resolve_upstream(&self, path: &str) -> Option { // Auth, users, roles, notifications, runtime-config, config, KB, support if path.starts_with("/api/auth") || path.starts_with("/api/users") || path.starts_with("/api/me") || path.starts_with("/api/profile") || path.starts_with("/api/onboarding") || path.starts_with("/api/roles") || path.starts_with("/api/notifications") || path.starts_with("/api/config") || path.starts_with("/api/kb") || path.starts_with("/api/packages") || path.starts_with("/api/support") || path.starts_with("/api/admin/roles") || path.starts_with("/api/admin/users") || path.starts_with("/api/admin/verifications") || path.starts_with("/api/admin/approvals") || path.starts_with("/api/admin/approval-cases") || path.starts_with("/api/admin/external-roles") || path.starts_with("/api/admin/dashboard-config") || path.starts_with("/api/admin/onboarding-config") || path.starts_with("/api/admin/dashboard") || path.starts_with("/api/admin/kb") || path.starts_with("/api/admin/support-cases") || path.starts_with("/api/admin/reviews") || path.starts_with("/api/admin/coupons") || path.starts_with("/api/admin/discounts") || path.starts_with("/api/admin/tracecoin-packages") || path.starts_with("/api/admin/reports") || path.starts_with("/api/admin/activity-logs") { Some(self.users_url.clone()) } // ── Employees / Internal Admin (NEW) ────────────────────────────── else if path.starts_with("/api/admin/auth") || path.starts_with("/api/admin/employees") || path.starts_with("/api/admin/departments") || path.starts_with("/api/admin/designations") { Some(self.employees_url.clone()) } // Companies + Jobs + Applications + Packages else if path.starts_with("/api/companies") || path.starts_with("/api/jobs") || path.starts_with("/api/applications") || path.starts_with("/api/pricing") || path.starts_with("/api/admin/companies") || path.starts_with("/api/admin/jobs") || path.starts_with("/api/admin/applications") { Some(self.companies_url.clone()) } // Job Seekers else if path.starts_with("/api/jobseeker") { Some(self.job_seekers_url.clone()) } // Customers + Leads else if path.starts_with("/api/customers") || path.starts_with("/api/admin/customers") || path.starts_with("/api/admin/leads") { Some(self.customers_url.clone()) } // ── 9 Separate Profession Services ──────────────────────────────── else if path.starts_with("/api/photographers") || path.starts_with("/api/admin/photographers") { Some(self.photographers_url.clone()) } else if path.starts_with("/api/makeup-artists") || path.starts_with("/api/admin/makeup-artists") { Some(self.makeup_artists_url.clone()) } else if path.starts_with("/api/tutors") || path.starts_with("/api/admin/tutors") { Some(self.tutors_url.clone()) } else if path.starts_with("/api/developers") || path.starts_with("/api/admin/developers") { Some(self.developers_url.clone()) } else if path.starts_with("/api/video-editors") || path.starts_with("/api/admin/video-editors") { Some(self.video_editors_url.clone()) } else if path.starts_with("/api/graphic-designers") || path.starts_with("/api/admin/graphic-designers") { Some(self.graphic_designers_url.clone()) } else if path.starts_with("/api/social-media-managers") || path.starts_with("/api/admin/social-media-managers") { Some(self.social_media_managers_url.clone()) } else if path.starts_with("/api/fitness-trainers") || path.starts_with("/api/admin/fitness-trainers") { Some(self.fitness_trainers_url.clone()) } else if path.starts_with("/api/catering-services") || path.starts_with("/api/admin/catering-services") { Some(self.catering_services_url.clone()) } else if path.starts_with("/api/ugc-content-creators") { Some(self.ugc_content_creators_url.clone()) } // ── Payments + Invoices ─────────────────────────────────────────── else if path.starts_with("/api/payments") || path.starts_with("/api/admin/invoices") || path.starts_with("/api/admin/credits") || path.starts_with("/api/admin/revenue") || path.starts_with("/api/admin/pricing") { Some(self.payments_url.clone()) } // Wallet / Tracecoins (shared across profession services — route to a credits service) else if path.starts_with("/api/credits") { Some(self.payments_url.clone()) } // Admin runtime config management defaults to users service else if path.starts_with("/api/admin/runtime-configs") { Some(self.users_url.clone()) } // Catch-all for any other admin endpoints → users service else if path.starts_with("/api/admin/") { Some(self.users_url.clone()) } else { None } } } fn build_cors() -> CorsLayer { let frontend_url = std::env::var("FRONTEND_URL") .unwrap_or_else(|_| "http://localhost:9201".to_string()); let admin_url = std::env::var("ADMIN_URL") .unwrap_or_else(|_| "http://localhost:9202".to_string()); let allowed_origins: Vec = vec![ frontend_url.parse().expect("Invalid FRONTEND_URL"), admin_url.parse().expect("Invalid ADMIN_URL"), ]; CorsLayer::new() .allow_origin(AllowOrigin::list(allowed_origins)) .allow_methods([ Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS, ]) .allow_headers(AllowHeaders::mirror_request()) .allow_credentials(true) } #[tokio::main] async fn main() { tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), )) .with(tracing_subscriber::fmt::layer()) .init(); let services = Services::from_env(); let cors = build_cors(); let app = Router::new() .route("/api/{*path}", any(proxy_handler)) .route("/health", any(|| async { "Gateway OK" })) .layer(cors) .with_state(services); let port: u16 = std::env::var("PORT") .unwrap_or_else(|_| "9100".to_string()) .parse() .expect("PORT must be a valid u16"); let addr = SocketAddr::from(([0, 0, 0, 0], port)); tracing::info!("Gateway listening on {}", addr); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); } async fn proxy_handler( State(services): State, req: Request, ) -> impl IntoResponse { let path = req.uri().path().to_string(); let query = req.uri().query().map(|q| format!("?{}", q)).unwrap_or_default(); let Some(upstream_base) = services.resolve_upstream(&path) else { return (StatusCode::NOT_FOUND, "Route not found in gateway").into_response(); }; let target_url = format!("{}{}{}", upstream_base, path, query); let method = req.method().clone(); let headers = req.headers().clone(); let body = req.into_body(); match services .client .request(method, &target_url) .headers(headers) .body(reqwest::Body::wrap_stream(body.into_data_stream())) .send() .await { Ok(res) => { let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); let res_headers = res.headers().clone(); let bytes = res.bytes().await.unwrap_or_default(); let res_body = Body::from(bytes); let mut response = res_body.into_response(); *response.status_mut() = status; for (name, value) in res_headers { if let Some(name) = name { response.headers_mut().insert(name, value); } } response } Err(e) => { tracing::error!("Gateway proxy error → {}: {}", target_url, e); (StatusCode::BAD_GATEWAY, format!("Gateway error: {}", e)).into_response() } } }