use std::sync::Arc; use axum::{ Json, Router, extract::{Path, Query, State}, http::HeaderMap, }; use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool, Postgres, QueryBuilder, postgres::PgQueryResult}; use utoipa::{ ToSchema, openapi::{Info, OpenApi, Server, Tag}, }; use utoipa_axum::{router::OpenApiRouter, routes}; use uuid::Uuid; use crate::{ AppState, error::{ApiError, CommonApiErrors, ErrorBody}, }; type SharedState = Arc; const DEFAULT_GRADE: &str = "艺术类"; const DEFAULT_SUBJECT: &str = "美术"; const DEFAULT_DURATION_MINUTES: i16 = 60; pub fn router() -> (Router, OpenApi) { let (router, mut openapi) = OpenApiRouter::new() .routes(routes!(health)) .routes(routes!(list_profiles, create_profile)) .routes(routes!(get_profile, update_profile, delete_profile)) .routes(routes!(get_defaults, update_defaults)) .routes(routes!(list_feedback_records, create_feedback_record)) .routes(routes!(delete_feedback_record)) .split_for_parts(); let mut info = Info::new("教学反馈助手 API", env!("CARGO_PKG_VERSION")); info.description = Some( "学生档案、档案预设和教学反馈记录 API。\n\n\ 业务接口使用 `X-User-Id` 作为开发阶段身份边界;Scalar 已预填测试 UUID 和请求体示例。" .to_owned(), ); openapi.info = info; openapi.servers = Some(vec![Server::new("/")]); openapi.tags = Some(vec![ api_tag("健康检查", "检查 HTTP 服务状态和数据库是否已配置。"), api_tag("学生档案", "创建、查询、更新和删除当前用户的学生档案。"), api_tag("档案预设", "读取或保存当前用户新建档案时使用的默认值。"), api_tag("反馈记录", "创建、查询和删除当前用户的教学反馈记录。"), ]); (router, openapi) } fn api_tag(name: &str, description: &str) -> Tag { let mut tag = Tag::new(name); tag.description = Some(description.to_owned()); tag } /// 健康检查响应。 #[derive(Serialize, ToSchema)] #[schema(example = json!({"status": "ok", "database_configured": true}))] struct HealthResponse { /// HTTP 服务状态;正常时固定为 `ok`。 status: &'static str, /// 是否已经通过 `DATABASE_URL` 配置数据库连接。 database_configured: bool, } /// 检查服务状态。 #[utoipa::path( get, path = "/health", tag = "健康检查", summary = "健康检查", description = "不需要身份请求头。返回 HTTP 服务状态,并标记数据库连接是否已配置。", responses( (status = 200, description = "服务正在运行", body = HealthResponse) ) )] async fn health(State(state): State) -> Json { Json(HealthResponse { status: "ok", database_configured: state.pool.is_some(), }) } /// 学生档案。 #[derive(Debug, Serialize, FromRow, ToSchema)] #[schema(example = json!({ "id": "11111111-1111-4111-8111-111111111111", "name": "小谢", "grade": "艺术类", "subject": "美术", "academic_year": 2026, "term": "暑", "guardian_title": "小谢妈妈", "start_time": "18:00", "end_time": "19:00", "created_at": "2026-07-15T10:00:00Z", "updated_at": "2026-07-15T10:00:00Z" }))] struct StudentProfile { /// 档案 ID。 id: Uuid, /// 学生姓名,1 到 20 个字符。 name: String, /// 年级或学生类别。 grade: String, /// 授课学科。 subject: String, /// 学年,范围为 2000 到 2200。 academic_year: i16, /// 学期,可选值为春、暑、秋、寒。 term: String, /// 家长称呼,1 到 24 个字符。 guardian_title: String, /// 上课开始时间,格式为 `HH:MM`。 start_time: String, /// 上课结束时间,格式为 `HH:MM`。 end_time: String, /// 创建时间,UTC。 created_at: DateTime, /// 最近更新时间,UTC。 updated_at: DateTime, } /// 创建或完整更新学生档案的请求体。 #[derive(Debug, Deserialize, ToSchema)] #[schema(example = json!({ "name": "小谢", "grade": "艺术类", "subject": "美术", "academic_year": 2026, "term": "暑", "guardian_title": "小谢妈妈", "start_time": "18:00", "end_time": "19:00" }))] struct ProfileInput { /// 学生姓名,1 到 20 个字符。 name: String, /// 年级或学生类别,不可为空。 grade: String, /// 授课学科,不可为空。 subject: String, /// 学年,范围为 2000 到 2200。 academic_year: i16, /// 学期,可选值为春、暑、秋、寒。 term: String, /// 家长称呼,1 到 24 个字符。 guardian_title: String, /// 上课开始时间,格式为 `HH:MM`。 start_time: String, /// 上课结束时间,格式为 `HH:MM`。 end_time: String, } #[derive(Debug, Deserialize)] struct ProfileQuery { query: Option, grade: Option, subject: Option, } /// 列出当前用户的学生档案。 #[utoipa::path( get, path = "/api/v1/profiles", tag = "学生档案", summary = "列出学生档案", description = "按最近更新时间倒序返回当前用户的档案。三个查询条件均可省略,也可以组合使用。", params( ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), ("query" = Option, Query, description = "模糊匹配姓名、年级、学科或家长称呼", example = "小谢"), ("grade" = Option, Query, description = "精确匹配年级或学生类别", example = "艺术类"), ("subject" = Option, Query, description = "精确匹配学科", example = "美术") ), responses( (status = 200, description = "学生档案列表", body = [StudentProfile]), CommonApiErrors ) )] async fn list_profiles( State(state): State, headers: HeaderMap, Query(query): Query, ) -> Result>, ApiError> { let owner_id = current_user(&headers)?; let pool = pool(&state)?; let mut statement = QueryBuilder::::new( "SELECT id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time, created_at, updated_at \ FROM student_profiles WHERE owner_id = ", ); statement.push_bind(owner_id); if let Some(grade) = query.grade.filter(|value| !value.trim().is_empty()) { statement.push(" AND grade = "); statement.push_bind(grade.trim().to_owned()); } if let Some(subject) = query.subject.filter(|value| !value.trim().is_empty()) { statement.push(" AND subject = "); statement.push_bind(subject.trim().to_owned()); } if let Some(keyword) = query.query.filter(|value| !value.trim().is_empty()) { let pattern = format!("%{}%", keyword.trim()); statement.push(" AND (name ILIKE "); statement.push_bind(pattern.clone()); statement.push(" OR grade ILIKE "); statement.push_bind(pattern.clone()); statement.push(" OR subject ILIKE "); statement.push_bind(pattern.clone()); statement.push(" OR guardian_title ILIKE "); statement.push_bind(pattern); statement.push(")"); } statement.push(" ORDER BY updated_at DESC"); let profiles = statement .build_query_as::() .fetch_all(pool) .await?; Ok(Json(profiles)) } /// 读取当前用户的单个学生档案。 #[utoipa::path( get, path = "/api/v1/profiles/{profile_id}", tag = "学生档案", summary = "读取学生档案", description = "只允许读取属于当前 `X-User-Id` 的档案。请先从创建或列表接口复制真实的档案 ID。", params( ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), ("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111")) ), responses( (status = 200, description = "学生档案", body = StudentProfile), (status = 404, description = "档案不存在或不属于当前用户", body = ErrorBody, example = json!({"error": "resource not found"})), CommonApiErrors ) )] async fn get_profile( State(state): State, headers: HeaderMap, Path(profile_id): Path, ) -> Result, ApiError> { let owner_id = current_user(&headers)?; let profile = sqlx::query_as::<_, StudentProfile>( "SELECT id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time, created_at, updated_at \ FROM student_profiles WHERE id = $1 AND owner_id = $2", ) .bind(profile_id) .bind(owner_id) .fetch_optional(pool(&state)?) .await? .ok_or(ApiError::NotFound)?; Ok(Json(profile)) } /// 创建学生档案。 #[utoipa::path( post, path = "/api/v1/profiles", tag = "学生档案", summary = "创建学生档案", description = "使用请求头中的用户 ID 创建档案。Scalar 已预填完整请求体,可以直接执行测试。", params( ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")) ), request_body( content = ProfileInput, description = "学生档案内容", content_type = "application/json", example = json!({ "name": "小谢", "grade": "艺术类", "subject": "美术", "academic_year": 2026, "term": "暑", "guardian_title": "小谢妈妈", "start_time": "18:00", "end_time": "19:00" }) ), responses( (status = 201, description = "档案创建成功", body = StudentProfile), CommonApiErrors ) )] async fn create_profile( State(state): State, headers: HeaderMap, Json(input): Json, ) -> Result<(axum::http::StatusCode, Json), ApiError> { validate_profile(&input)?; let owner_id = current_user(&headers)?; let profile = sqlx::query_as::<_, StudentProfile>( "INSERT INTO student_profiles \ (id, owner_id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ RETURNING id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time, created_at, updated_at", ) .bind(Uuid::new_v4()) .bind(owner_id) .bind(input.name.trim()) .bind(input.grade.trim()) .bind(input.subject.trim()) .bind(input.academic_year) .bind(input.term.trim()) .bind(input.guardian_title.trim()) .bind(input.start_time.trim()) .bind(input.end_time.trim()) .fetch_one(pool(&state)?) .await?; Ok((axum::http::StatusCode::CREATED, Json(profile))) } /// 完整更新学生档案。 #[utoipa::path( put, path = "/api/v1/profiles/{profile_id}", tag = "学生档案", summary = "更新学生档案", description = "完整替换指定档案的可编辑字段。请先从创建或列表接口复制真实的档案 ID。", params( ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), ("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111")) ), request_body( content = ProfileInput, description = "更新后的完整档案内容", content_type = "application/json", example = json!({ "name": "小谢", "grade": "艺术类", "subject": "美术", "academic_year": 2026, "term": "暑", "guardian_title": "小谢妈妈", "start_time": "18:00", "end_time": "19:00" }) ), responses( (status = 200, description = "更新后的学生档案", body = StudentProfile), (status = 404, description = "档案不存在或不属于当前用户", body = ErrorBody, example = json!({"error": "resource not found"})), CommonApiErrors ) )] async fn update_profile( State(state): State, headers: HeaderMap, Path(profile_id): Path, Json(input): Json, ) -> Result, ApiError> { validate_profile(&input)?; let owner_id = current_user(&headers)?; let profile = sqlx::query_as::<_, StudentProfile>( "UPDATE student_profiles \ SET name = $1, grade = $2, subject = $3, academic_year = $4, term = $5, guardian_title = $6, \ start_time = $7, end_time = $8, updated_at = now() \ WHERE id = $9 AND owner_id = $10 \ RETURNING id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time, created_at, updated_at", ) .bind(input.name.trim()) .bind(input.grade.trim()) .bind(input.subject.trim()) .bind(input.academic_year) .bind(input.term.trim()) .bind(input.guardian_title.trim()) .bind(input.start_time.trim()) .bind(input.end_time.trim()) .bind(profile_id) .bind(owner_id) .fetch_optional(pool(&state)?) .await? .ok_or(ApiError::NotFound)?; Ok(Json(profile)) } /// 删除学生档案。 #[utoipa::path( delete, path = "/api/v1/profiles/{profile_id}", tag = "学生档案", summary = "删除学生档案", description = "删除属于当前用户的档案。关联反馈记录不会被删除,其 `profile_id` 会被置空。", params( ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), ("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111")) ), responses( (status = 204, description = "档案已删除"), (status = 404, description = "档案不存在或不属于当前用户", body = ErrorBody, example = json!({"error": "resource not found"})), CommonApiErrors ) )] async fn delete_profile( State(state): State, headers: HeaderMap, Path(profile_id): Path, ) -> Result { let owner_id = current_user(&headers)?; let result = sqlx::query("DELETE FROM student_profiles WHERE id = $1 AND owner_id = $2") .bind(profile_id) .bind(owner_id) .execute(pool(&state)?) .await?; affected_or_not_found(result)?; Ok(axum::http::StatusCode::NO_CONTENT) } /// 当前用户的新建档案预设。 #[derive(Debug, Serialize, FromRow, ToSchema)] #[schema(example = json!({ "grade": "艺术类", "subject": "美术", "lesson_duration_minutes": 60, "updated_at": "2026-07-15T10:00:00Z" }))] struct ProfileDefaults { /// 默认年级或学生类别。 grade: String, /// 默认授课学科。 subject: String, /// 默认课程时长,必须为 15 到 360 之间的 15 的倍数。 lesson_duration_minutes: i16, /// 最近更新时间,UTC;未保存过预设时为本次请求时间。 updated_at: DateTime, } /// 保存档案预设的请求体。 #[derive(Debug, Deserialize, ToSchema)] #[schema(example = json!({ "grade": "艺术类", "subject": "美术", "lesson_duration_minutes": 60 }))] struct ProfileDefaultsInput { /// 默认年级或学生类别,不可为空。 grade: String, /// 默认授课学科,不可为空。 subject: String, /// 默认课程时长,必须为 15 到 360 之间的 15 的倍数。 lesson_duration_minutes: i16, } /// 读取当前用户的新建档案预设。 #[utoipa::path( get, path = "/api/v1/profile-defaults", tag = "档案预设", summary = "读取档案预设", description = "读取当前用户的预设;尚未保存时直接返回艺术类、美术、60 分钟的系统默认值。", params( ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")) ), responses( (status = 200, description = "当前档案预设", body = ProfileDefaults), CommonApiErrors ) )] async fn get_defaults( State(state): State, headers: HeaderMap, ) -> Result, ApiError> { let owner_id = current_user(&headers)?; let defaults = sqlx::query_as::<_, ProfileDefaults>( "SELECT grade, subject, lesson_duration_minutes, updated_at \ FROM student_profile_defaults WHERE owner_id = $1", ) .bind(owner_id) .fetch_optional(pool(&state)?) .await?; Ok(Json(defaults.unwrap_or_else(default_profile_defaults))) } /// 保存当前用户的新建档案预设。 #[utoipa::path( put, path = "/api/v1/profile-defaults", tag = "档案预设", summary = "保存档案预设", description = "新增或覆盖当前用户的档案预设。Scalar 已预填完整请求体,可以直接执行测试。", params( ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")) ), request_body( content = ProfileDefaultsInput, description = "要保存的档案预设", content_type = "application/json", example = json!({ "grade": "艺术类", "subject": "美术", "lesson_duration_minutes": 60 }) ), responses( (status = 200, description = "保存后的档案预设", body = ProfileDefaults), CommonApiErrors ) )] async fn update_defaults( State(state): State, headers: HeaderMap, Json(input): Json, ) -> Result, ApiError> { validate_defaults(&input)?; let owner_id = current_user(&headers)?; let defaults = sqlx::query_as::<_, ProfileDefaults>( "INSERT INTO student_profile_defaults (owner_id, grade, subject, lesson_duration_minutes) \ VALUES ($1, $2, $3, $4) \ ON CONFLICT (owner_id) DO UPDATE SET \ grade = EXCLUDED.grade, \ subject = EXCLUDED.subject, \ lesson_duration_minutes = EXCLUDED.lesson_duration_minutes, \ updated_at = now() \ RETURNING grade, subject, lesson_duration_minutes, updated_at", ) .bind(owner_id) .bind(input.grade.trim()) .bind(input.subject.trim()) .bind(input.lesson_duration_minutes) .fetch_one(pool(&state)?) .await?; Ok(Json(defaults)) } /// 教学反馈记录。 #[derive(Debug, Serialize, FromRow, ToSchema)] #[schema(example = json!({ "id": "22222222-2222-4222-8222-222222222222", "profile_id": null, "feedback_date": "2026-07-15", "content": "今天完成了色彩搭配练习,构图有明显进步。", "created_at": "2026-07-15T10:00:00Z", "updated_at": "2026-07-15T10:00:00Z" }))] struct FeedbackRecord { /// 反馈记录 ID。 id: Uuid, /// 关联的学生档案 ID;可以为空。 profile_id: Option, /// 反馈对应日期,格式为 `YYYY-MM-DD`。 feedback_date: NaiveDate, /// 教学反馈正文。 content: String, /// 创建时间,UTC。 created_at: DateTime, /// 最近更新时间,UTC。 updated_at: DateTime, } /// 创建教学反馈记录的请求体。 #[derive(Debug, Deserialize, ToSchema)] #[schema(example = json!({ "profile_id": null, "feedback_date": "2026-07-15", "content": "今天完成了色彩搭配练习,构图有明显进步。" }))] struct FeedbackRecordInput { /// 可选的学生档案 ID;如填写,必须属于当前用户。Scalar 默认留空以便直接测试。 profile_id: Option, /// 反馈对应日期,格式为 `YYYY-MM-DD`。 feedback_date: NaiveDate, /// 教学反馈正文,不可为空。 content: String, } #[derive(Debug, Deserialize)] struct FeedbackRecordQuery { profile_id: Option, } /// 列出当前用户的教学反馈记录。 #[utoipa::path( get, path = "/api/v1/feedback-records", tag = "反馈记录", summary = "列出反馈记录", description = "按反馈日期和更新时间倒序返回当前用户的记录;可按学生档案 ID 筛选。", params( ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), ("profile_id" = Option, Query, description = "只返回关联到指定档案的记录;直接测试时可留空", example = json!("11111111-1111-4111-8111-111111111111")) ), responses( (status = 200, description = "教学反馈记录列表", body = [FeedbackRecord]), CommonApiErrors ) )] async fn list_feedback_records( State(state): State, headers: HeaderMap, Query(query): Query, ) -> Result>, ApiError> { let owner_id = current_user(&headers)?; let pool = pool(&state)?; let mut statement = QueryBuilder::::new( "SELECT id, profile_id, feedback_date, content, created_at, updated_at \ FROM feedback_records WHERE owner_id = ", ); statement.push_bind(owner_id); if let Some(profile_id) = query.profile_id { statement.push(" AND profile_id = "); statement.push_bind(profile_id); } statement.push(" ORDER BY feedback_date DESC, updated_at DESC"); let records = statement .build_query_as::() .fetch_all(pool) .await?; Ok(Json(records)) } /// 创建教学反馈记录。 #[utoipa::path( post, path = "/api/v1/feedback-records", tag = "反馈记录", summary = "创建反馈记录", description = "创建一条教学反馈。Scalar 默认将 `profile_id` 设为 null,因此无需先创建档案即可直接测试。", params( ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")) ), request_body( content = FeedbackRecordInput, description = "教学反馈内容", content_type = "application/json", example = json!({ "profile_id": null, "feedback_date": "2026-07-15", "content": "今天完成了色彩搭配练习,构图有明显进步。" }) ), responses( (status = 201, description = "反馈记录创建成功", body = FeedbackRecord), (status = 404, description = "指定的学生档案不存在或不属于当前用户", body = ErrorBody, example = json!({"error": "resource not found"})), CommonApiErrors ) )] async fn create_feedback_record( State(state): State, headers: HeaderMap, Json(input): Json, ) -> Result<(axum::http::StatusCode, Json), ApiError> { if input.content.trim().is_empty() { return Err(ApiError::BadRequest("content cannot be empty".to_owned())); } let owner_id = current_user(&headers)?; if let Some(profile_id) = input.profile_id { assert_profile_owner(pool(&state)?, owner_id, profile_id).await?; } let record = sqlx::query_as::<_, FeedbackRecord>( "INSERT INTO feedback_records (id, owner_id, profile_id, feedback_date, content) \ VALUES ($1, $2, $3, $4, $5) \ RETURNING id, profile_id, feedback_date, content, created_at, updated_at", ) .bind(Uuid::new_v4()) .bind(owner_id) .bind(input.profile_id) .bind(input.feedback_date) .bind(input.content.trim()) .fetch_one(pool(&state)?) .await?; Ok((axum::http::StatusCode::CREATED, Json(record))) } /// 删除教学反馈记录。 #[utoipa::path( delete, path = "/api/v1/feedback-records/{record_id}", tag = "反馈记录", summary = "删除反馈记录", description = "删除属于当前用户的反馈记录。请先从创建或列表接口复制真实的记录 ID。", params( ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与记录所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), ("record_id" = Uuid, Path, description = "反馈记录 ID;将示例值替换为创建接口返回的 ID", example = json!("22222222-2222-4222-8222-222222222222")) ), responses( (status = 204, description = "反馈记录已删除"), (status = 404, description = "记录不存在或不属于当前用户", body = ErrorBody, example = json!({"error": "resource not found"})), CommonApiErrors ) )] async fn delete_feedback_record( State(state): State, headers: HeaderMap, Path(record_id): Path, ) -> Result { let owner_id = current_user(&headers)?; let result = sqlx::query("DELETE FROM feedback_records WHERE id = $1 AND owner_id = $2") .bind(record_id) .bind(owner_id) .execute(pool(&state)?) .await?; affected_or_not_found(result)?; Ok(axum::http::StatusCode::NO_CONTENT) } fn current_user(headers: &HeaderMap) -> Result { let value = headers .get("x-user-id") .and_then(|header| header.to_str().ok()) .ok_or(ApiError::Unauthorized)?; Uuid::parse_str(value).map_err(|_| ApiError::BadRequest("X-User-Id must be a UUID".to_owned())) } fn pool(state: &AppState) -> Result<&PgPool, ApiError> { state.pool.as_ref().ok_or(ApiError::DatabaseUnavailable) } fn validate_profile(input: &ProfileInput) -> Result<(), ApiError> { if input.name.trim().is_empty() || input.name.trim().chars().count() > 20 { return Err(ApiError::BadRequest( "name must contain 1 to 20 characters".to_owned(), )); } if input.grade.trim().is_empty() || input.subject.trim().is_empty() { return Err(ApiError::BadRequest( "grade and subject are required".to_owned(), )); } if !(2000..=2200).contains(&input.academic_year) { return Err(ApiError::BadRequest( "academic_year must be between 2000 and 2200".to_owned(), )); } if !matches!(input.term.as_str(), "春" | "暑" | "秋" | "寒") { return Err(ApiError::BadRequest( "term must be 春, 暑, 秋, or 寒".to_owned(), )); } if input.guardian_title.trim().is_empty() || input.guardian_title.trim().chars().count() > 24 { return Err(ApiError::BadRequest( "guardian_title must contain 1 to 24 characters".to_owned(), )); } validate_time(&input.start_time, "start_time")?; validate_time(&input.end_time, "end_time") } fn validate_defaults(input: &ProfileDefaultsInput) -> Result<(), ApiError> { if input.grade.trim().is_empty() || input.subject.trim().is_empty() { return Err(ApiError::BadRequest( "grade and subject are required".to_owned(), )); } if !(15..=360).contains(&input.lesson_duration_minutes) || input.lesson_duration_minutes % 15 != 0 { return Err(ApiError::BadRequest( "lesson_duration_minutes must be a multiple of 15 between 15 and 360".to_owned(), )); } Ok(()) } fn validate_time(value: &str, field: &str) -> Result<(), ApiError> { NaiveTime::parse_from_str(value, "%H:%M") .map(|_| ()) .map_err(|_| ApiError::BadRequest(format!("{field} must use HH:MM format"))) } fn default_profile_defaults() -> ProfileDefaults { ProfileDefaults { grade: DEFAULT_GRADE.to_owned(), subject: DEFAULT_SUBJECT.to_owned(), lesson_duration_minutes: DEFAULT_DURATION_MINUTES, updated_at: Utc::now(), } } fn affected_or_not_found(result: PgQueryResult) -> Result<(), ApiError> { if result.rows_affected() == 0 { return Err(ApiError::NotFound); } Ok(()) } async fn assert_profile_owner( pool: &PgPool, owner_id: Uuid, profile_id: Uuid, ) -> Result<(), ApiError> { let exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM student_profiles WHERE id = $1 AND owner_id = $2)", ) .bind(profile_id) .bind(owner_id) .fetch_one(pool) .await?; if exists { Ok(()) } else { Err(ApiError::NotFound) } } #[cfg(test)] mod tests { use serde_json::Value; use super::router; const TEST_USER_ID: &str = "5a4da7f0-d70c-465f-bcab-124c504aa9f0"; #[test] fn openapi_documents_all_routes_and_default_test_data() { let (_, openapi) = router(); let document = serde_json::to_value(openapi).expect("OpenAPI should serialize"); let paths = document["paths"] .as_object() .expect("OpenAPI should contain paths"); let operations = paths .values() .flat_map(|path| { path.as_object() .expect("path item should be an object") .values() }) .filter(|operation| operation.get("responses").is_some()) .collect::>(); assert_eq!(operations.len(), 11); for operation in &operations { assert_non_empty(operation, "summary"); assert_non_empty(operation, "description"); } let request_examples = operations .iter() .filter_map(|operation| { operation.pointer("/requestBody/content/application~1json/example") }) .collect::>(); assert_eq!(request_examples.len(), 4); assert!(request_examples.iter().all(|example| example.is_object())); let business_operations = paths .iter() .filter(|(path, _)| path.starts_with("/api/v1/")) .flat_map(|(_, path)| { path.as_object() .expect("path item should be an object") .values() }) .filter(|operation| operation.get("responses").is_some()); for operation in business_operations { let user_header = operation["parameters"] .as_array() .expect("business operation should have parameters") .iter() .find(|parameter| parameter["name"] == "X-User-Id" && parameter["in"] == "header") .expect("business operation should document X-User-Id"); assert_eq!(user_header["example"], TEST_USER_ID); } assert_eq!(document["servers"][0]["url"], "/"); assert!(document["info"].get("contact").is_none()); assert!(document["info"].get("license").is_none()); assert!(document["components"]["schemas"]["ProfileInput"]["example"].is_object()); assert!(document["components"]["schemas"]["ProfileDefaultsInput"]["example"].is_object()); assert!( document["components"]["schemas"]["FeedbackRecordInput"]["example"]["profile_id"] .is_null() ); } fn assert_non_empty(operation: &Value, field: &str) { assert!( operation[field] .as_str() .is_some_and(|value| !value.trim().is_empty()), "operation should have a non-empty {field}" ); } }