From 9972357cf6d552d7683e7723406105a9348e85c3 Mon Sep 17 00:00:00 2001 From: Xevion Date: Wed, 27 Aug 2025 11:58:57 -0500 Subject: [PATCH] feat: implement simple web service, improve ServiceManager encapsulation --- src/app_state.rs | 11 +++--- src/banner/scraper.rs | 5 +-- src/config/mod.rs | 10 ++++-- src/main.rs | 31 ++++++++++++---- src/services/manager.rs | 50 +++++++++++++++----------- src/services/mod.rs | 3 ++ src/services/web.rs | 79 +++++++++++++++++++++++++++++++++++++++++ src/web/mod.rs | 5 +++ src/web/routes.rs | 76 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 235 insertions(+), 35 deletions(-) create mode 100644 src/services/web.rs create mode 100644 src/web/mod.rs create mode 100644 src/web/routes.rs diff --git a/src/app_state.rs b/src/app_state.rs index 4e19e59..49a28c4 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -6,23 +6,24 @@ use anyhow::Result; use redis::AsyncCommands; use redis::Client; use serde_json; +use std::sync::Arc; #[derive(Clone, Debug)] pub struct AppState { - pub banner_api: std::sync::Arc, - pub redis: std::sync::Arc, + pub banner_api: Arc, + pub redis: Arc, } impl AppState { pub fn new( - banner_api: BannerApi, + banner_api: Arc, redis_url: &str, ) -> Result> { let redis_client = Client::open(redis_url)?; Ok(Self { - banner_api: std::sync::Arc::new(banner_api), - redis: std::sync::Arc::new(redis_client), + banner_api, + redis: Arc::new(redis_client), }) } diff --git a/src/banner/scraper.rs b/src/banner/scraper.rs index 81ea353..23214a8 100644 --- a/src/banner/scraper.rs +++ b/src/banner/scraper.rs @@ -3,6 +3,7 @@ use crate::banner::{api::BannerApi, models::*, query::SearchQuery}; use anyhow::{Context, Result}; use redis::AsyncCommands; +use std::sync::Arc; use std::time::Duration; use tokio::time; use tracing::{debug, error, info, warn}; @@ -15,13 +16,13 @@ const MAX_PAGE_SIZE: i32 = 500; /// Course scraper for Banner API pub struct CourseScraper { - api: BannerApi, + api: Arc, redis_client: redis::Client, } impl CourseScraper { /// Creates a new course scraper - pub fn new(api: BannerApi, redis_url: &str) -> Result { + pub fn new(api: Arc, redis_url: &str) -> Result { let redis_client = redis::Client::open(redis_url).context("Failed to create Redis client")?; diff --git a/src/config/mod.rs b/src/config/mod.rs index 533ca48..62433c1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -3,8 +3,6 @@ //! This module handles loading and parsing configuration from environment variables //! using the figment crate. It supports flexible duration parsing that accepts both //! numeric values (interpreted as seconds) and duration strings with units. -//! -//! All configuration is loaded from environment variables with the `APP_` prefix: use fundu::{DurationParser, TimeUnit}; use serde::{Deserialize, Deserializer}; @@ -15,6 +13,9 @@ use std::time::Duration; pub struct Config { /// Discord bot token for authentication pub bot_token: String, + /// Port for the web server + #[serde(default = "default_port")] + pub port: u16, /// Database connection URL pub database_url: String, /// Redis connection URL @@ -36,6 +37,11 @@ pub struct Config { pub shutdown_timeout: Duration, } +/// Default port of 3000 +fn default_port() -> u16 { + 3000 +} + /// Default shutdown timeout of 8 seconds fn default_shutdown_timeout() -> Duration { Duration::from_secs(8) diff --git a/src/main.rs b/src/main.rs index afdabb8..6c8d418 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,17 +5,21 @@ use tracing_subscriber::{EnvFilter, FmtSubscriber}; use crate::app_state::AppState; use crate::banner::BannerApi; +use crate::banner::scraper::CourseScraper; use crate::bot::{Data, get_commands}; use crate::config::Config; use crate::services::manager::ServiceManager; -use crate::services::{ServiceResult, bot::BotService, run_service}; +use crate::services::{ServiceResult, bot::BotService, web::WebService}; +use crate::web::routes::BannerState; use figment::{Figment, providers::Env}; +use std::sync::Arc; mod app_state; mod banner; mod bot; mod config; mod services; +mod web; #[tokio::main] async fn main() { @@ -51,8 +55,19 @@ async fn main() { .await .expect("Failed to set up BannerApi session"); - let app_state = - AppState::new(banner_api, &config.redis_url).expect("Failed to create AppState"); + let banner_api_arc = Arc::new(banner_api); + let app_state = AppState::new(banner_api_arc.clone(), &config.redis_url) + .expect("Failed to create AppState"); + + // Create CourseScraper for web service + let scraper = CourseScraper::new(banner_api_arc.clone(), &config.redis_url) + .expect("Failed to create CourseScraper"); + + // Create BannerState for web service + let banner_state = BannerState { + api: banner_api_arc, + scraper: Arc::new(scraper), + }; // Configure the client with your Discord bot token in the environment let intents = GatewayIntents::non_privileged(); @@ -86,16 +101,20 @@ async fn main() { // Extract shutdown timeout before moving config let shutdown_timeout = config.shutdown_timeout; + let port = config.port; // Create service manager let mut service_manager = ServiceManager::new(); - // Create and add services + // Register services with the manager let bot_service = Box::new(BotService::new(client)); + let web_service = Box::new(WebService::new(port, banner_state)); - let bot_handle = tokio::spawn(run_service(bot_service, service_manager.subscribe())); + service_manager.register_service("bot", bot_service); + service_manager.register_service("web", web_service); - service_manager.add_service("bot".to_string(), bot_handle); + // Spawn all registered services + service_manager.spawn_all(); // Set up CTRL+C signal handling let ctrl_c = async { diff --git a/src/services/manager.rs b/src/services/manager.rs index 011bee1..4113347 100644 --- a/src/services/manager.rs +++ b/src/services/manager.rs @@ -4,11 +4,12 @@ use tokio::sync::broadcast; use tokio::task::JoinHandle; use tracing::{error, info, warn}; -use crate::services::ServiceResult; +use crate::services::{Service, ServiceResult, run_service}; /// Manages multiple services and their lifecycle pub struct ServiceManager { - services: HashMap>, + registered_services: HashMap>, + running_services: HashMap>, shutdown_tx: broadcast::Sender<()>, } @@ -16,45 +17,54 @@ impl ServiceManager { pub fn new() -> Self { let (shutdown_tx, _) = broadcast::channel(1); Self { - services: HashMap::new(), + registered_services: HashMap::new(), + running_services: HashMap::new(), shutdown_tx, } } - /// Add a service to be managed - pub fn add_service(&mut self, name: String, handle: JoinHandle) { - self.services.insert(name, handle); + /// Register a service to be managed (not yet spawned) + pub fn register_service(&mut self, name: &str, service: Box) { + self.registered_services.insert(name.to_string(), service); } - /// Get a shutdown receiver for services to subscribe to - pub fn subscribe(&self) -> broadcast::Receiver<()> { - self.shutdown_tx.subscribe() + /// Spawn all registered services + pub fn spawn_all(&mut self) { + for (name, service) in self.registered_services.drain() { + let shutdown_rx = self.shutdown_tx.subscribe(); + let handle = tokio::spawn(run_service(service, shutdown_rx)); + self.running_services.insert(name, handle); + } + info!("Spawned {} services", self.running_services.len()); } /// Run all services until one completes or fails /// Returns the first service that completes and its result pub async fn run(&mut self) -> (String, ServiceResult) { - if self.services.is_empty() { + if self.running_services.is_empty() { return ( "none".to_string(), ServiceResult::Error(anyhow::anyhow!("No services to run")), ); } - info!("ServiceManager running {} services", self.services.len()); + info!( + "ServiceManager running {} services", + self.running_services.len() + ); // Wait for any service to complete loop { let mut completed_services = Vec::new(); - for (name, handle) in &mut self.services { + for (name, handle) in &mut self.running_services { if handle.is_finished() { completed_services.push(name.clone()); } } if let Some(completed_name) = completed_services.first() { - let handle = self.services.remove(completed_name).unwrap(); + let handle = self.running_services.remove(completed_name).unwrap(); match handle.await { Ok(result) => { return (completed_name.clone(), result); @@ -77,14 +87,14 @@ impl ServiceManager { /// Shutdown all services gracefully with a timeout /// Returns Ok(()) if all services shut down, or Err(Vec) with names of services that timed out pub async fn shutdown(mut self, timeout: Duration) -> Result<(), Vec> { - if self.services.is_empty() { + if self.running_services.is_empty() { info!("No services to shutdown"); return Ok(()); } info!( "Shutting down {} services with {}s timeout", - self.services.len(), + self.running_services.len(), timeout.as_secs() ); @@ -96,17 +106,17 @@ impl ServiceManager { let mut completed = Vec::new(); let mut failed = Vec::new(); - while !self.services.is_empty() { + while !self.running_services.is_empty() { let mut to_remove = Vec::new(); - for (name, handle) in &mut self.services { + for (name, handle) in &mut self.running_services { if handle.is_finished() { to_remove.push(name.clone()); } } for name in to_remove { - let handle = self.services.remove(&name).unwrap(); + let handle = self.running_services.remove(&name).unwrap(); match handle.await { Ok(ServiceResult::GracefulShutdown) => { completed.push(name); @@ -126,7 +136,7 @@ impl ServiceManager { } } - if !self.services.is_empty() { + if !self.running_services.is_empty() { tokio::time::sleep(Duration::from_millis(10)).await; } } @@ -147,7 +157,7 @@ impl ServiceManager { } Err(_) => { // Timeout occurred - return names of services that didn't complete - let pending_services: Vec = self.services.keys().cloned().collect(); + let pending_services: Vec = self.running_services.keys().cloned().collect(); Err(pending_services) } } diff --git a/src/services/mod.rs b/src/services/mod.rs index 4f67922..b236d36 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -3,6 +3,7 @@ use tracing::{error, info, warn}; pub mod bot; pub mod manager; +pub mod web; #[derive(Debug)] pub enum ServiceResult { @@ -21,6 +22,8 @@ pub trait Service: Send + Sync { async fn run(&mut self) -> Result<(), anyhow::Error>; /// Gracefully shutdown the service + /// + /// An 'Ok' result does not mean the service has completed shutdown, it merely means that the service shutdown was initiated. async fn shutdown(&mut self) -> Result<(), anyhow::Error>; } diff --git a/src/services/web.rs b/src/services/web.rs new file mode 100644 index 0000000..339e9d3 --- /dev/null +++ b/src/services/web.rs @@ -0,0 +1,79 @@ +use super::Service; +use crate::web::routes::{BannerState, create_banner_router}; +use std::net::SocketAddr; +use tokio::net::TcpListener; +use tokio::sync::broadcast; +use tracing::{debug, info, warn}; + +/// Web server service implementation +pub struct WebService { + port: u16, + banner_state: BannerState, + shutdown_tx: Option>, +} + +impl WebService { + pub fn new(port: u16, banner_state: BannerState) -> Self { + Self { + port, + banner_state, + shutdown_tx: None, + } + } +} + +#[async_trait::async_trait] +impl Service for WebService { + fn name(&self) -> &'static str { + "web" + } + + async fn run(&mut self) -> Result<(), anyhow::Error> { + // Create the main router with Banner API routes + let app = create_banner_router(self.banner_state.clone()); + + let addr = SocketAddr::from(([0, 0, 0, 0], self.port)); + info!( + service = "web", + link = format!("http://localhost:{}", addr.port()), + "Starting web server", + ); + + let listener = TcpListener::bind(addr).await?; + debug!( + service = "web", + "Web server listening on {}", + format!("http://{}", addr) + ); + + // Create internal shutdown channel for axum graceful shutdown + let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1); + self.shutdown_tx = Some(shutdown_tx); + + // Use axum's graceful shutdown with the internal shutdown signal + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.recv().await; + debug!( + service = "web", + "Received shutdown signal, starting graceful shutdown" + ); + }) + .await?; + + info!(service = "web", "Web server stopped"); + Ok(()) + } + + async fn shutdown(&mut self) -> Result<(), anyhow::Error> { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } else { + warn!( + service = "web", + "No shutdown channel found, cannot trigger graceful shutdown" + ); + } + Ok(()) + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..2f27b41 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,5 @@ +//! Web API module for the banner application. + +pub mod routes; + +pub use routes::*; diff --git a/src/web/routes.rs b/src/web/routes.rs new file mode 100644 index 0000000..e8804d6 --- /dev/null +++ b/src/web/routes.rs @@ -0,0 +1,76 @@ +//! Web API endpoints for Banner bot monitoring and metrics. + +use axum::{Router, extract::State, response::Json, routing::get}; +use serde_json::{Value, json}; +use std::sync::Arc; + +/// Shared application state for web server +#[derive(Clone)] +pub struct BannerState { + pub api: Arc, + pub scraper: Arc, +} + +/// Creates the web server router +pub fn create_banner_router(state: BannerState) -> Router { + Router::new() + .route("/", get(root)) + .route("/status", get(status)) + .route("/metrics", get(metrics)) + .with_state(state) +} + +/// Root endpoint - shows API info +async fn root() -> Json { + Json(json!({ + "message": "Banner Discord Bot API", + "version": "0.1.0", + "endpoints": { + "health": "/health", + "status": "/status", + "metrics": "/metrics" + } + })) +} + +/// Status endpoint showing bot and system status +async fn status(State(_state): State) -> Json { + // For now, return basic status without accessing private fields + Json(json!({ + "status": "operational", + "bot": { + "status": "running", + "uptime": "TODO: implement uptime tracking" + }, + "cache": { + "status": "connected", + "courses": "TODO: implement course counting", + "subjects": "TODO: implement subject counting" + }, + "banner_api": { + "status": "connected" + }, + "timestamp": chrono::Utc::now().to_rfc3339() + })) +} + +/// Metrics endpoint for monitoring +async fn metrics(State(_state): State) -> Json { + // For now, return basic metrics structure + Json(json!({ + "redis": { + "status": "connected", + "connected_clients": "TODO: implement client counting", + "used_memory": "TODO: implement memory tracking" + }, + "cache": { + "courses": { + "count": "TODO: implement course counting" + }, + "subjects": { + "count": "TODO: implement subject counting" + } + }, + "timestamp": chrono::Utc::now().to_rfc3339() + })) +}