//! 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, FromRequestParts, Request}; use axum::http::request::Parts; 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())), } } } /// `axum::extract::Query` whose extraction failures become /// [`AppError::BadRequest`] instead of axum's bare `text/plain` rejection. /// /// Query strings are the one input a visitor edits by hand or inherits from a /// stale link, so a malformed one is the most likely way to meet an error page /// at all. Left as a raw `Query` it answers with unlayouted framework text /// (loose-wire g2-06); routed through here it gets the same branded template, /// `HX-Error` header and JSON-on-API-routes treatment as every other failure. /// /// 400 rather than the 422 the body extractors use: nothing was submitted for /// the server to process, the address itself is wrong. pub struct ValidatedQuery(pub T); impl FromRequestParts for ValidatedQuery where T: DeserializeOwned, S: Send + Sync, { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { match axum::extract::Query::::from_request_parts(parts, state).await { Ok(axum::extract::Query(value)) => Ok(Self(value)), Err(rejection) => Err(rejection_to_bad_request(&rejection.body_text())), } } } /// `axum_extra::extract::Query` (repeated-param query) with the same /// treatment as [`ValidatedQuery`]. /// /// Separate from `ValidatedQuery` for the same reason `ValidatedHtmlForm` is /// separate from `ValidatedForm`: only the `axum_extra` extractor collects /// `?tag=a&tag=b` into a `Vec`, and swapping one for the other silently turns /// every multi-select filter on the discover page into a 400. pub struct ValidatedExtraQuery(pub T); impl FromRequestParts for ValidatedExtraQuery where T: DeserializeOwned, S: Send + Sync, { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { match axum_extra::extract::Query::::from_request_parts(parts, state).await { Ok(axum_extra::extract::Query(value)) => Ok(Self(value)), Err(rejection) => Err(rejection_to_bad_request(&rejection.to_string())), } } } /// Same prefix-stripping as [`rejection_to_validation`], for the query-string /// wording axum uses, then wrapped as a 400. fn rejection_to_bad_request(detail: &str) -> AppError { let msg = detail .strip_prefix("Failed to deserialize query string: ") .unwrap_or(detail) .trim(); let msg = if msg.is_empty() { "That link has a bad address. Please check it and try again.".to_string() } else { format!("That link has a bad address: {msg}") }; AppError::BadRequest(msg) } /// 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."); } }