feat: implement comprehensive course data model with reference cache and search

This commit is contained in:
2026-01-28 21:06:29 -06:00
parent e3b855b956
commit 6df4303bd6
16 changed files with 1121 additions and 76 deletions
+289 -3
View File
@@ -3,7 +3,8 @@
use axum::{
Router,
body::Body,
extract::{Request, State},
extract::{Path, Query, Request, State},
http::StatusCode as AxumStatusCode,
response::{Json, Response},
routing::get,
};
@@ -14,7 +15,7 @@ use axum::{
};
#[cfg(feature = "embed-assets")]
use http::header;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{collections::BTreeMap, time::Duration};
@@ -71,6 +72,11 @@ pub fn create_router(app_state: AppState) -> Router {
.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);
@@ -249,7 +255,10 @@ async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
);
}
let overall_status = if services.values().any(|s| matches!(s.status, ServiceStatus::Error)) {
let overall_status = if services
.values()
.any(|s| matches!(s.status, ServiceStatus::Error))
{
ServiceStatus::Error
} else if !services.is_empty()
&& services
@@ -281,3 +290,280 @@ async fn metrics() -> Json<Value> {
"timestamp": chrono::Utc::now().to_rfc3339()
}))
}
// ============================================================
// Course search & detail API
// ============================================================
#[derive(Deserialize)]
struct SearchParams {
term: String,
subject: Option<String>,
q: Option<String>,
course_number_low: Option<i32>,
course_number_high: Option<i32>,
#[serde(default)]
open_only: bool,
instructional_method: Option<String>,
campus: Option<String>,
#[serde(default = "default_limit")]
limit: i32,
#[serde(default)]
offset: i32,
}
fn default_limit() -> i32 {
25
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CourseResponse {
crn: String,
subject: String,
course_number: String,
title: String,
term_code: String,
sequence_number: Option<String>,
instructional_method: Option<String>,
campus: Option<String>,
enrollment: i32,
max_enrollment: i32,
wait_count: i32,
wait_capacity: i32,
credit_hours: Option<i32>,
credit_hour_low: Option<i32>,
credit_hour_high: Option<i32>,
cross_list: Option<String>,
cross_list_capacity: Option<i32>,
cross_list_count: Option<i32>,
link_identifier: Option<String>,
is_section_linked: Option<bool>,
part_of_term: Option<String>,
meeting_times: Value,
attributes: Value,
instructors: Vec<InstructorResponse>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct InstructorResponse {
banner_id: String,
display_name: String,
email: Option<String>,
is_primary: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SearchResponse {
courses: Vec<CourseResponse>,
total_count: i64,
offset: i32,
limit: i32,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
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)| InstructorResponse {
banner_id,
display_name,
email,
is_primary,
},
)
.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: course.meeting_times.clone(),
attributes: course.attributes.clone(),
instructors,
}
}
/// `GET /api/courses/search`
async fn search_courses(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
) -> Result<Json<SearchResponse>, (AxumStatusCode, String)> {
let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0);
let (courses, total_count) = crate::data::courses::search_courses(
&state.db_pool,
&params.term,
params.subject.as_deref(),
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,
)
.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,
offset,
limit,
}))
}
/// `GET /api/courses/:term/:crn`
async fn get_course(
State(state): State<AppState>,
Path((term, crn)): Path<(String, String)>,
) -> Result<Json<CourseResponse>, (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<AppState>,
) -> Result<Json<Vec<CodeDescription>>, (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<CodeDescription> = 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=202420`
async fn get_subjects(
State(state): State<AppState>,
) -> Result<Json<Vec<CodeDescription>>, (AxumStatusCode, String)> {
let cache = state.reference_cache.read().await;
let entries = cache.entries_for_category("subject");
let subjects: Vec<CodeDescription> = entries
.into_iter()
.map(|(code, description)| CodeDescription {
code: code.to_string(),
description: description.to_string(),
})
.collect();
Ok(Json(subjects))
}
/// `GET /api/reference/:category`
async fn get_reference(
State(state): State<AppState>,
Path(category): Path<String>,
) -> Result<Json<Vec<CodeDescription>>, (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(),
))
}