use std::fmt; use std::str::FromStr; use axum::extract::{FromRequestParts, Path}; use axum::http::request::Parts; use serde::{Deserialize, Deserializer}; use uuid::Uuid; use crate::error::AppError; /// Typed wrapper around `uuid::Uuid` for case identifiers. Enforces the /// "parse, don't validate" idiom: any `CaseId` in code has already passed /// UUID validation at the request boundary. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct CaseId(pub Uuid); impl fmt::Display for CaseId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fmt::Display::fmt(&self.0, f) } } impl FromStr for CaseId { type Err = uuid::Error; fn from_str(s: &str) -> Result { Uuid::parse_str(s).map(CaseId) } } impl<'de> Deserialize<'de> for CaseId { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; CaseId::from_str(&s).map_err(serde::de::Error::custom) } } /// Axum Path-extractor that validates the `{case_id}` URL segment and /// returns `AppError::BadRequest("Invalid case_id")` on failure — matching /// the legacy error body byte-for-byte. pub struct CaseIdPath(pub CaseId); impl FromRequestParts for CaseIdPath where S: Send + Sync, { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let Path(s) = Path::::from_request_parts(parts, state) .await .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; let id = CaseId::from_str(&s).map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; Ok(CaseIdPath(id)) } } #[cfg(test)] mod tests { use super::*; #[test] fn from_str_accepts_valid_uuid() { let id = CaseId::from_str("11111111-1111-1111-1111-111111111111").unwrap(); assert_eq!(id.0.to_string(), "11111111-1111-1111-1111-111111111111"); } #[test] fn from_str_rejects_non_uuid() { assert!(CaseId::from_str("not-a-uuid").is_err()); } #[test] fn display_matches_uuid_display() { let raw = "22222222-2222-2222-2222-222222222222"; let id = CaseId::from_str(raw).unwrap(); assert_eq!(id.to_string(), raw); } }