nxtgauge-backend-rust/apps/gateway/src/main.rs

298 lines
13 KiB
Rust

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:8080".to_string()),
companies_url: std::env::var("COMPANIES_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8081".to_string()),
job_seekers_url: std::env::var("JOB_SEEKERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8082".to_string()),
customers_url: std::env::var("CUSTOMERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8083".to_string()),
photographers_url: std::env::var("PHOTOGRAPHERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8085".to_string()),
makeup_artists_url: std::env::var("MAKEUP_ARTISTS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8086".to_string()),
tutors_url: std::env::var("TUTORS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8087".to_string()),
developers_url: std::env::var("DEVELOPERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8088".to_string()),
video_editors_url: std::env::var("VIDEO_EDITORS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8089".to_string()),
graphic_designers_url: std::env::var("GRAPHIC_DESIGNERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8090".to_string()),
social_media_managers_url: std::env::var("SOCIAL_MEDIA_MANAGERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8091".to_string()),
fitness_trainers_url: std::env::var("FITNESS_TRAINERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8092".to_string()),
catering_services_url: std::env::var("CATERING_SERVICES_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8093".to_string()),
ugc_content_creators_url: std::env::var("UGC_CONTENT_CREATORS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8095".to_string()),
payments_url: std::env::var("PAYMENTS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8094".to_string()),
employees_url: std::env::var("EMPLOYEES_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:8096".to_string()),
client: reqwest::Client::new(),
}
}
fn resolve_upstream(&self, path: &str) -> Option<String> {
// 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:3000".to_string());
let admin_url = std::env::var("ADMIN_URL")
.unwrap_or_else(|_| "http://localhost:3001".to_string());
let allowed_origins: Vec<HeaderValue> = 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(|_| "8000".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<Services>,
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()
}
}
}