feat: implement simple web service, improve ServiceManager encapsulation

This commit is contained in:
2025-08-27 11:58:57 -05:00
parent 2ec899cf25
commit 9972357cf6
9 changed files with 235 additions and 35 deletions

View File

@@ -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<BannerApi>,
pub redis: std::sync::Arc<Client>,
pub banner_api: Arc<BannerApi>,
pub redis: Arc<Client>,
}
impl AppState {
pub fn new(
banner_api: BannerApi,
banner_api: Arc<BannerApi>,
redis_url: &str,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
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),
})
}

View File

@@ -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<BannerApi>,
redis_client: redis::Client,
}
impl CourseScraper {
/// Creates a new course scraper
pub fn new(api: BannerApi, redis_url: &str) -> Result<Self> {
pub fn new(api: Arc<BannerApi>, redis_url: &str) -> Result<Self> {
let redis_client =
redis::Client::open(redis_url).context("Failed to create Redis client")?;

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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<String, JoinHandle<ServiceResult>>,
registered_services: HashMap<String, Box<dyn Service>>,
running_services: HashMap<String, JoinHandle<ServiceResult>>,
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<ServiceResult>) {
self.services.insert(name, handle);
/// Register a service to be managed (not yet spawned)
pub fn register_service(&mut self, name: &str, service: Box<dyn Service>) {
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<String>) with names of services that timed out
pub async fn shutdown(mut self, timeout: Duration) -> Result<(), Vec<String>> {
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<String> = self.services.keys().cloned().collect();
let pending_services: Vec<String> = self.running_services.keys().cloned().collect();
Err(pending_services)
}
}

View File

@@ -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>;
}

79
src/services/web.rs Normal file
View File

@@ -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<broadcast::Sender<()>>,
}
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(())
}
}

5
src/web/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
//! Web API module for the banner application.
pub mod routes;
pub use routes::*;

76
src/web/routes.rs Normal file
View File

@@ -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<crate::banner::BannerApi>,
pub scraper: Arc<crate::banner::scraper::CourseScraper>,
}
/// 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<Value> {
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<BannerState>) -> Json<Value> {
// 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<BannerState>) -> Json<Value> {
// 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()
}))
}