//! Body extractors that route deserialization/validation failures through //! [`AppError::validation`] instead of axum's raw plain-text rejection (UX-S4). //! //! Several request structs embed validating newtypes (e.g. `Slug`, `PriceCents`) //! whose `Deserialize` impls reject bad input. With a bare `Form`/`Json` //! that rejection surfaces as axum's default plain-text 4xx, no friendly error //! template, no JSON-on-API-routes treatment, no preserved status semantics. These //! wrappers map any extraction failure to `AppError::validation`, so the request //! flows through the same error path as an explicit validation failure: the //! friendly template for page routes, `{"error": "..."}` for API routes (via //! `json_error_layer`), and a 422. use axum::extract::{FromRequest, Request}; use serde::de::DeserializeOwned; use crate::error::AppError; /// `axum::Form` whose extraction failures become [`AppError::validation`]. pub struct ValidatedForm(pub T); impl FromRequest for ValidatedForm where T: DeserializeOwned, S: Send + Sync, { type Rejection = AppError; async fn from_request(req: Request, state: &S) -> Result { match axum::Form::::from_request(req, state).await { Ok(axum::Form(value)) => Ok(Self(value)), Err(rejection) => Err(rejection_to_validation(&rejection.body_text())), } } } /// `axum::Json` whose extraction failures become [`AppError::validation`]. pub struct ValidatedJson(pub T); impl FromRequest for ValidatedJson where T: DeserializeOwned, S: Send + Sync, { type Rejection = AppError; async fn from_request(req: Request, state: &S) -> Result { match axum::Json::::from_request(req, state).await { Ok(axum::Json(value)) => Ok(Self(value)), Err(rejection) => Err(rejection_to_validation(&rejection.body_text())), } } } /// `axum_extra::extract::Form` (repeated-field form) whose extraction failures /// become [`AppError::validation`]. Used by the project wizard, whose `BasicsForm` /// embeds a validating `Slug` newtype, without this, a bad slug fails extraction /// with a raw rejection while a bad title (validated in-handler) got the friendly /// path, the exact split the audit called out (UX-S4). pub struct ValidatedHtmlForm(pub T); impl FromRequest for ValidatedHtmlForm where T: DeserializeOwned, S: Send + Sync, { type Rejection = AppError; async fn from_request(req: Request, state: &S) -> Result { match axum_extra::extract::Form::::from_request(req, state).await { Ok(axum_extra::extract::Form(value)) => Ok(Self(value)), Err(rejection) => Err(rejection_to_validation(&rejection.to_string())), } } } /// Strip axum's "Failed to deserialize ...: " machinery so the user-facing message /// is the underlying cause (which, for a validating newtype, is its own error /// string). Falls back to a generic message when nothing useful remains. fn rejection_to_validation(detail: &str) -> AppError { let msg = detail .strip_prefix("Failed to deserialize form body: ") .or_else(|| { detail.strip_prefix("Failed to deserialize the JSON body into the target type: ") }) .unwrap_or(detail) .trim(); let msg = if msg.is_empty() { "Please check your input and try again.".to_string() } else { msg.to_string() }; AppError::validation(msg) } #[cfg(test)] mod tests { use super::*; #[test] fn strips_form_prefix() { let e = rejection_to_validation("Failed to deserialize form body: slug must be lowercase"); assert_eq!(e.user_message(), "slug must be lowercase"); } #[test] fn strips_json_prefix() { let e = rejection_to_validation( "Failed to deserialize the JSON body into the target type: price too high", ); assert_eq!(e.user_message(), "price too high"); } #[test] fn falls_back_when_empty() { let e = rejection_to_validation(""); assert_eq!(e.user_message(), "Please check your input and try again."); } }