use axum::{
Router,
routing::{get, post},
};
use tower_http::cors::CorsLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use sea_orm::DatabaseConnection;
pub mod handlers;
pub mod state;
pub use state::AppState;
use crate::Result;
#[derive(OpenApi)]
#[openapi(
info(
title = "Course Service API",
// Sourced from Cargo.toml at compile time so the OpenAPI
// info block can't drift away from the crate version.
version = env!("CARGO_PKG_VERSION"),
description = "schema.org/Course-aligned identity registry — CRUD, search, matching, merging, audit, privacy."
),
paths(
handlers::health,
handlers::create_course,
handlers::get_course,
handlers::update_course,
handlers::delete_course,
handlers::search_courses,
handlers::check_duplicates,
handlers::match_course,
handlers::merge_courses,
handlers::deduplicate,
handlers::list_instances,
handlers::create_instance,
handlers::get_instance,
handlers::update_instance_handler,
handlers::delete_instance,
handlers::masked_course,
handlers::export_course_data,
handlers::audit_for_course,
handlers::audit_recent,
),
components(schemas(
crate::api::ApiError,
crate::models::Course,
crate::models::CourseStatus,
crate::models::EducationalLevel,
crate::models::LearningResourceType,
crate::models::InteractivityType,
crate::models::CourseLink,
crate::models::LinkType,
crate::models::CourseIdentifier,
crate::models::IdentifierType,
crate::models::CourseInstance,
crate::models::CourseMode,
crate::models::CourseInstanceStatus,
crate::models::Schedule,
crate::models::course_instance::Session,
crate::models::EducationalCredential,
crate::models::CredentialCategory,
crate::models::Syllabus,
crate::models::Provider,
crate::models::ProviderKind,
crate::models::MergeRequest,
crate::models::MergeResponse,
crate::models::MergeRecord,
crate::models::MergeStatus,
crate::models::BatchDeduplicationRequest,
crate::models::BatchDeduplicationResponse,
crate::models::ReviewQueueItem,
crate::models::ReviewStatus,
crate::matching::MatchBreakdown,
crate::validation::ValidationError,
crate::db::audit::AuditEntry,
handlers::HealthResponse,
handlers::SearchQuery,
handlers::SearchResponse,
handlers::ScoredCandidate,
handlers::AuditQuery,
)),
tags(
(name = "health", description = "Liveness probe"),
(name = "courses", description = "Course CRUD"),
(name = "instances", description = "CourseInstance sub-resource"),
(name = "search", description = "Full-text + fuzzy search"),
(name = "matching", description = "Match / dedup / merge"),
(name = "privacy", description = "Masking + GDPR export"),
(name = "audit", description = "Audit log queries"),
),
)]
pub struct ApiDoc;
pub fn create_router(state: AppState) -> Router {
let api_routes = Router::new()
.route("/health", get(handlers::health))
.route(
"/courses",
get(handlers::not_implemented).post(handlers::create_course),
)
.route("/courses/search", get(handlers::search_courses))
.route("/courses/match", post(handlers::match_course))
.route(
"/courses/check-duplicates",
post(handlers::check_duplicates),
)
.route("/courses/merge", post(handlers::merge_courses))
.route("/courses/deduplicate", post(handlers::deduplicate))
.route(
"/courses/:id",
get(handlers::get_course)
.put(handlers::update_course)
.delete(handlers::delete_course),
)
.route(
"/courses/:id/instances",
get(handlers::list_instances).post(handlers::create_instance),
)
.route(
"/courses/:id/instances/:instance_id",
get(handlers::get_instance)
.put(handlers::update_instance_handler)
.delete(handlers::delete_instance),
)
.route("/courses/:id/export", get(handlers::export_course_data))
.route("/courses/:id/masked", get(handlers::masked_course))
.route("/courses/:id/audit", get(handlers::audit_for_course))
.route("/audit/recent", get(handlers::audit_recent))
.with_state(state);
Router::new()
.nest("/api", api_routes)
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
.layer(CorsLayer::permissive())
}
pub async fn serve(state: AppState) -> Result<()> {
let app = create_router(state.clone());
let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| crate::Error::Api(e.to_string()))?;
tracing::info!("REST API server listening on {}", addr);
axum::serve(listener, app)
.await
.map_err(|e| crate::Error::Api(e.to_string()))?;
Ok(())
}
pub fn build_state(
db: DatabaseConnection,
search_engine: crate::search::SearchEngine,
matcher: crate::matching::CourseMatcher,
config: crate::config::Config,
) -> AppState {
AppState::new(db, search_engine, matcher, config)
}