Skip to main content

max / makenotwork

4.2 KB · 119 lines History Blame Raw
1 //! Body extractors that route deserialization/validation failures through
2 //! [`AppError::validation`] instead of axum's raw plain-text rejection (UX-S4).
3 //!
4 //! Several request structs embed validating newtypes (e.g. `Slug`, `PriceCents`)
5 //! whose `Deserialize` impls reject bad input. With a bare `Form<T>`/`Json<T>`
6 //! that rejection surfaces as axum's default plain-text 4xx, no friendly error
7 //! template, no JSON-on-API-routes treatment, no preserved status semantics. These
8 //! wrappers map any extraction failure to `AppError::validation`, so the request
9 //! flows through the same error path as an explicit validation failure: the
10 //! friendly template for page routes, `{"error": "..."}` for API routes (via
11 //! `json_error_layer`), and a 422.
12
13 use axum::extract::{FromRequest, Request};
14 use serde::de::DeserializeOwned;
15
16 use crate::error::AppError;
17
18 /// `axum::Form<T>` whose extraction failures become [`AppError::validation`].
19 pub struct ValidatedForm<T>(pub T);
20
21 impl<T, S> FromRequest<S> for ValidatedForm<T>
22 where
23 T: DeserializeOwned,
24 S: Send + Sync,
25 {
26 type Rejection = AppError;
27
28 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
29 match axum::Form::<T>::from_request(req, state).await {
30 Ok(axum::Form(value)) => Ok(Self(value)),
31 Err(rejection) => Err(rejection_to_validation(&rejection.body_text())),
32 }
33 }
34 }
35
36 /// `axum::Json<T>` whose extraction failures become [`AppError::validation`].
37 pub struct ValidatedJson<T>(pub T);
38
39 impl<T, S> FromRequest<S> for ValidatedJson<T>
40 where
41 T: DeserializeOwned,
42 S: Send + Sync,
43 {
44 type Rejection = AppError;
45
46 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
47 match axum::Json::<T>::from_request(req, state).await {
48 Ok(axum::Json(value)) => Ok(Self(value)),
49 Err(rejection) => Err(rejection_to_validation(&rejection.body_text())),
50 }
51 }
52 }
53
54 /// `axum_extra::extract::Form<T>` (repeated-field form) whose extraction failures
55 /// become [`AppError::validation`]. Used by the project wizard, whose `BasicsForm`
56 /// embeds a validating `Slug` newtype, without this, a bad slug fails extraction
57 /// with a raw rejection while a bad title (validated in-handler) got the friendly
58 /// path, the exact split the audit called out (UX-S4).
59 pub struct ValidatedHtmlForm<T>(pub T);
60
61 impl<T, S> FromRequest<S> for ValidatedHtmlForm<T>
62 where
63 T: DeserializeOwned,
64 S: Send + Sync,
65 {
66 type Rejection = AppError;
67
68 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
69 match axum_extra::extract::Form::<T>::from_request(req, state).await {
70 Ok(axum_extra::extract::Form(value)) => Ok(Self(value)),
71 Err(rejection) => Err(rejection_to_validation(&rejection.to_string())),
72 }
73 }
74 }
75
76 /// Strip axum's "Failed to deserialize ...: " machinery so the user-facing message
77 /// is the underlying cause (which, for a validating newtype, is its own error
78 /// string). Falls back to a generic message when nothing useful remains.
79 fn rejection_to_validation(detail: &str) -> AppError {
80 let msg = detail
81 .strip_prefix("Failed to deserialize form body: ")
82 .or_else(|| {
83 detail.strip_prefix("Failed to deserialize the JSON body into the target type: ")
84 })
85 .unwrap_or(detail)
86 .trim();
87 let msg = if msg.is_empty() {
88 "Please check your input and try again.".to_string()
89 } else {
90 msg.to_string()
91 };
92 AppError::validation(msg)
93 }
94
95 #[cfg(test)]
96 mod tests {
97 use super::*;
98
99 #[test]
100 fn strips_form_prefix() {
101 let e = rejection_to_validation("Failed to deserialize form body: slug must be lowercase");
102 assert_eq!(e.user_message(), "slug must be lowercase");
103 }
104
105 #[test]
106 fn strips_json_prefix() {
107 let e = rejection_to_validation(
108 "Failed to deserialize the JSON body into the target type: price too high",
109 );
110 assert_eq!(e.user_message(), "price too high");
111 }
112
113 #[test]
114 fn falls_back_when_empty() {
115 let e = rejection_to_validation("");
116 assert_eq!(e.user_message(), "Please check your input and try again.");
117 }
118 }
119