//! Web API endpoints for Banner bot monitoring and metrics. use axum::{ Router, body::Body, extract::{Path, Query, Request, State}, http::StatusCode as AxumStatusCode, response::{Json, Response}, routing::get, }; #[cfg(feature = "embed-assets")] use axum::{ http::{HeaderMap, HeaderValue, StatusCode, Uri}, response::{Html, IntoResponse}, }; #[cfg(feature = "embed-assets")] use http::header; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::{collections::BTreeMap, time::Duration}; use ts_rs::TS; use crate::state::AppState; use crate::status::ServiceStatus; #[cfg(not(feature = "embed-assets"))] use tower_http::cors::{Any, CorsLayer}; use tower_http::{classify::ServerErrorsFailureClass, timeout::TimeoutLayer, trace::TraceLayer}; use tracing::{Span, debug, trace, warn}; #[cfg(feature = "embed-assets")] use crate::web::assets::{WebAssets, get_asset_metadata_cached}; /// Set appropriate caching headers based on asset type #[cfg(feature = "embed-assets")] fn set_caching_headers(response: &mut Response, path: &str, etag: &str) { let headers = response.headers_mut(); // Set ETag if let Ok(etag_value) = HeaderValue::from_str(etag) { headers.insert(header::ETAG, etag_value); } // Set Cache-Control based on asset type let cache_control = if path.starts_with("assets/") { // Static assets with hashed filenames - long-term cache "public, max-age=31536000, immutable" } else if path == "index.html" { // HTML files - short-term cache "public, max-age=300" } else { match path.split_once('.').map(|(_, extension)| extension) { Some(ext) => match ext { // CSS/JS files - medium-term cache "css" | "js" => "public, max-age=86400", // Images - long-term cache "png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" => "public, max-age=2592000", // Default for other files _ => "public, max-age=3600", }, // Default for files without an extension None => "public, max-age=3600", } }; if let Ok(cache_control_value) = HeaderValue::from_str(cache_control) { headers.insert(header::CACHE_CONTROL, cache_control_value); } } /// Creates the web server router pub fn create_router(app_state: AppState) -> Router { let api_router = Router::new() .route("/health", get(health)) .route("/status", get(status)) .route("/metrics", get(metrics)) .route("/courses/search", get(search_courses)) .route("/courses/{term}/{crn}", get(get_course)) .route("/terms", get(get_terms)) .route("/subjects", get(get_subjects)) .route("/reference/{category}", get(get_reference)) .with_state(app_state); let mut router = Router::new().nest("/api", api_router); // When embed-assets feature is enabled, serve embedded static assets #[cfg(feature = "embed-assets")] { router = router.fallback(fallback); } // Without embed-assets, enable CORS for dev proxy to Vite #[cfg(not(feature = "embed-assets"))] { router = router.layer( CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any), ); } router.layer(( TraceLayer::new_for_http() .make_span_with(|request: &Request| { tracing::debug_span!("request", path = request.uri().path()) }) .on_request(()) .on_body_chunk(()) .on_eos(()) .on_response( |response: &Response, latency: Duration, _span: &Span| { let latency_threshold = if cfg!(debug_assertions) { Duration::from_millis(100) } else { Duration::from_millis(1000) }; // Format latency, status, and code let (latency_str, status) = ( format!("{latency:.2?}"), format!( "{} {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("??") ), ); // Log in warn if latency is above threshold, otherwise debug if latency > latency_threshold { warn!(latency = latency_str, status = status, "Response"); } else { debug!(latency = latency_str, status = status, "Response"); } }, ) .on_failure( |error: ServerErrorsFailureClass, latency: Duration, _span: &Span| { warn!( error = ?error, latency = format!("{latency:.2?}"), "Request failed" ); }, ), TimeoutLayer::new(Duration::from_secs(10)), )) } /// Handler that extracts request information for caching #[cfg(feature = "embed-assets")] async fn fallback(request: Request) -> Response { let uri = request.uri().clone(); let headers = request.headers().clone(); handle_spa_fallback_with_headers(uri, headers).await } /// Handles SPA routing by serving index.html for non-API, non-asset requests /// This version includes HTTP caching headers and ETag support #[cfg(feature = "embed-assets")] async fn handle_spa_fallback_with_headers(uri: Uri, request_headers: HeaderMap) -> Response { let path = uri.path().trim_start_matches('/'); if let Some(content) = WebAssets::get(path) { // Get asset metadata (MIME type and hash) with caching let metadata = get_asset_metadata_cached(path, &content.data); // Check if client has a matching ETag (conditional request) if let Some(etag) = request_headers.get(header::IF_NONE_MATCH) && etag.to_str().is_ok_and(|s| metadata.etag_matches(s)) { return StatusCode::NOT_MODIFIED.into_response(); } // Use cached MIME type, only set Content-Type if we have a valid MIME type let mut response = ( [( header::CONTENT_TYPE, // For unknown types, set to application/octet-stream metadata .mime_type .unwrap_or("application/octet-stream".to_string()), )], content.data, ) .into_response(); // Set caching headers set_caching_headers(&mut response, path, &metadata.hash.quoted()); return response; } else { // Any assets that are not found should be treated as a 404, not falling back to the SPA index.html if path.starts_with("assets/") { return (StatusCode::NOT_FOUND, "Asset not found").into_response(); } } // Fall back to the SPA index.html match WebAssets::get("index.html") { Some(content) => { let metadata = get_asset_metadata_cached("index.html", &content.data); // Check if client has a matching ETag for index.html if let Some(etag) = request_headers.get(header::IF_NONE_MATCH) && etag.to_str().is_ok_and(|s| metadata.etag_matches(s)) { return StatusCode::NOT_MODIFIED.into_response(); } let mut response = Html(content.data).into_response(); set_caching_headers(&mut response, "index.html", &metadata.hash.quoted()); response } None => ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to load index.html", ) .into_response(), } } /// Health check endpoint async fn health() -> Json { trace!("health check requested"); Json(json!({ "status": "healthy", "timestamp": chrono::Utc::now().to_rfc3339() })) } #[derive(Serialize, TS)] #[ts(export)] pub struct ServiceInfo { name: String, status: ServiceStatus, } #[derive(Serialize, TS)] #[ts(export)] pub struct StatusResponse { status: ServiceStatus, version: String, commit: String, services: BTreeMap, } /// Status endpoint showing bot and system status async fn status(State(state): State) -> Json { let mut services = BTreeMap::new(); for (name, svc_status) in state.service_statuses.all() { services.insert( name.clone(), ServiceInfo { name, status: svc_status, }, ); } let overall_status = if services .values() .any(|s| matches!(s.status, ServiceStatus::Error)) { ServiceStatus::Error } else if !services.is_empty() && services .values() .all(|s| matches!(s.status, ServiceStatus::Active | ServiceStatus::Connected)) { ServiceStatus::Active } else if services.is_empty() { ServiceStatus::Disabled } else { ServiceStatus::Active }; Json(StatusResponse { status: overall_status, version: env!("CARGO_PKG_VERSION").to_string(), commit: env!("GIT_COMMIT_HASH").to_string(), services, }) } /// Metrics endpoint for monitoring async fn metrics() -> Json { // For now, return basic metrics structure Json(json!({ "banner_api": { "status": "connected" }, "timestamp": chrono::Utc::now().to_rfc3339() })) } // ============================================================ // Course search & detail API // ============================================================ #[derive(Deserialize)] struct SubjectsParams { term: String, } #[derive(Deserialize)] struct SearchParams { term: String, #[serde(default)] subject: Vec, q: Option, course_number_low: Option, course_number_high: Option, #[serde(default)] open_only: bool, instructional_method: Option, campus: Option, #[serde(default = "default_limit")] limit: i32, #[serde(default)] offset: i32, sort_by: Option, sort_dir: Option, } #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "snake_case")] enum SortColumn { CourseCode, Title, Instructor, Time, Seats, } #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "snake_case")] enum SortDirection { Asc, Desc, } fn default_limit() -> i32 { 25 } /// Build a safe ORDER BY clause from the validated sort column and direction. fn sort_clause(column: Option, direction: Option) -> String { let dir = match direction.unwrap_or(SortDirection::Asc) { SortDirection::Asc => "ASC", SortDirection::Desc => "DESC", }; match column { Some(SortColumn::CourseCode) => { format!("subject {dir}, course_number {dir}, sequence_number {dir}") } Some(SortColumn::Title) => format!("title {dir}"), Some(SortColumn::Instructor) => { // Sort by primary instructor display name via a subquery format!( "(SELECT i.display_name FROM course_instructors ci \ JOIN instructors i ON i.banner_id = ci.instructor_id \ WHERE ci.course_id = courses.id AND ci.is_primary = true \ LIMIT 1) {dir} NULLS LAST" ) } Some(SortColumn::Time) => { // Sort by first meeting time's begin_time via JSONB format!("(meeting_times->0->>'begin_time') {dir} NULLS LAST") } Some(SortColumn::Seats) => { format!("(max_enrollment - enrollment) {dir}") } None => "subject ASC, course_number ASC, sequence_number ASC".to_string(), } } #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct CourseResponse { crn: String, subject: String, course_number: String, title: String, term_code: String, sequence_number: Option, instructional_method: Option, campus: Option, enrollment: i32, max_enrollment: i32, wait_count: i32, wait_capacity: i32, credit_hours: Option, credit_hour_low: Option, credit_hour_high: Option, cross_list: Option, cross_list_capacity: Option, cross_list_count: Option, link_identifier: Option, is_section_linked: Option, part_of_term: Option, meeting_times: Vec, attributes: Vec, instructors: Vec, } #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct InstructorResponse { banner_id: String, display_name: String, email: Option, is_primary: bool, rmp_rating: Option, rmp_num_ratings: Option, } #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct SearchResponse { courses: Vec, total_count: i32, offset: i32, limit: i32, } #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct CodeDescription { code: String, description: String, } /// Build a `CourseResponse` from a DB course, fetching its instructors. async fn build_course_response( course: &crate::data::models::Course, db_pool: &sqlx::PgPool, ) -> CourseResponse { let instructors = crate::data::courses::get_course_instructors(db_pool, course.id) .await .unwrap_or_default() .into_iter() .map( |(banner_id, display_name, email, is_primary, rmp_rating, rmp_num_ratings)| { InstructorResponse { banner_id, display_name, email, is_primary, rmp_rating, rmp_num_ratings, } }, ) .collect(); CourseResponse { crn: course.crn.clone(), subject: course.subject.clone(), course_number: course.course_number.clone(), title: course.title.clone(), term_code: course.term_code.clone(), sequence_number: course.sequence_number.clone(), instructional_method: course.instructional_method.clone(), campus: course.campus.clone(), enrollment: course.enrollment, max_enrollment: course.max_enrollment, wait_count: course.wait_count, wait_capacity: course.wait_capacity, credit_hours: course.credit_hours, credit_hour_low: course.credit_hour_low, credit_hour_high: course.credit_hour_high, cross_list: course.cross_list.clone(), cross_list_capacity: course.cross_list_capacity, cross_list_count: course.cross_list_count, link_identifier: course.link_identifier.clone(), is_section_linked: course.is_section_linked, part_of_term: course.part_of_term.clone(), meeting_times: serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(), attributes: serde_json::from_value(course.attributes.clone()).unwrap_or_default(), instructors, } } /// `GET /api/courses/search` async fn search_courses( State(state): State, axum_extra::extract::Query(params): axum_extra::extract::Query, ) -> Result, (AxumStatusCode, String)> { let limit = params.limit.clamp(1, 100); let offset = params.offset.max(0); let order_by = sort_clause(params.sort_by, params.sort_dir); let (courses, total_count) = crate::data::courses::search_courses( &state.db_pool, ¶ms.term, if params.subject.is_empty() { None } else { Some(¶ms.subject) }, params.q.as_deref(), params.course_number_low, params.course_number_high, params.open_only, params.instructional_method.as_deref(), params.campus.as_deref(), limit, offset, &order_by, ) .await .map_err(|e| { tracing::error!(error = %e, "Course search failed"); ( AxumStatusCode::INTERNAL_SERVER_ERROR, "Search failed".to_string(), ) })?; let mut course_responses = Vec::with_capacity(courses.len()); for course in &courses { course_responses.push(build_course_response(course, &state.db_pool).await); } Ok(Json(SearchResponse { courses: course_responses, total_count: total_count as i32, offset, limit, })) } /// `GET /api/courses/:term/:crn` async fn get_course( State(state): State, Path((term, crn)): Path<(String, String)>, ) -> Result, (AxumStatusCode, String)> { let course = crate::data::courses::get_course_by_crn(&state.db_pool, &crn, &term) .await .map_err(|e| { tracing::error!(error = %e, "Course lookup failed"); ( AxumStatusCode::INTERNAL_SERVER_ERROR, "Lookup failed".to_string(), ) })? .ok_or_else(|| (AxumStatusCode::NOT_FOUND, "Course not found".to_string()))?; Ok(Json(build_course_response(&course, &state.db_pool).await)) } /// `GET /api/terms` async fn get_terms( State(state): State, ) -> Result>, (AxumStatusCode, String)> { let cache = state.reference_cache.read().await; let term_codes = crate::data::courses::get_available_terms(&state.db_pool) .await .map_err(|e| { tracing::error!(error = %e, "Failed to get terms"); ( AxumStatusCode::INTERNAL_SERVER_ERROR, "Failed to get terms".to_string(), ) })?; let terms: Vec = term_codes .into_iter() .map(|code| { let description = cache .lookup("term", &code) .unwrap_or("Unknown Term") .to_string(); CodeDescription { code, description } }) .collect(); Ok(Json(terms)) } /// `GET /api/subjects?term=202620` async fn get_subjects( State(state): State, Query(params): Query, ) -> Result>, (AxumStatusCode, String)> { let rows = crate::data::courses::get_subjects_by_enrollment(&state.db_pool, ¶ms.term) .await .map_err(|e| { tracing::error!(error = %e, "Failed to get subjects"); ( AxumStatusCode::INTERNAL_SERVER_ERROR, "Failed to get subjects".to_string(), ) })?; let subjects: Vec = rows .into_iter() .map(|(code, description, _enrollment)| CodeDescription { code, description }) .collect(); Ok(Json(subjects)) } /// `GET /api/reference/:category` async fn get_reference( State(state): State, Path(category): Path, ) -> Result>, (AxumStatusCode, String)> { let cache = state.reference_cache.read().await; let entries = cache.entries_for_category(&category); if entries.is_empty() { // Fall back to DB query in case cache doesn't have this category drop(cache); let rows = crate::data::reference::get_by_category(&category, &state.db_pool) .await .map_err(|e| { tracing::error!(error = %e, category = %category, "Reference lookup failed"); ( AxumStatusCode::INTERNAL_SERVER_ERROR, "Lookup failed".to_string(), ) })?; return Ok(Json( rows.into_iter() .map(|r| CodeDescription { code: r.code, description: r.description, }) .collect(), )); } Ok(Json( entries .into_iter() .map(|(code, desc)| CodeDescription { code: code.to_string(), description: desc.to_string(), }) .collect(), )) }