mirror of
https://github.com/Xevion/banner.git
synced 2025-12-15 06:11:11 -06:00
feat: implement simple web service, improve ServiceManager encapsulation
This commit is contained in:
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
31
src/main.rs
31
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
79
src/services/web.rs
Normal 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
5
src/web/mod.rs
Normal 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
76
src/web/routes.rs
Normal 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()
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user