Skip to main content

max / makenotwork

6.8 KB · 187 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, FromRequestParts, Request};
14 use axum::http::request::Parts;
15 use serde::de::DeserializeOwned;
16
17 use crate::error::AppError;
18
19 /// `axum::Form<T>` whose extraction failures become [`AppError::validation`].
20 pub struct ValidatedForm<T>(pub T);
21
22 impl<T, S> FromRequest<S> for ValidatedForm<T>
23 where
24 T: DeserializeOwned,
25 S: Send + Sync,
26 {
27 type Rejection = AppError;
28
29 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
30 match axum::Form::<T>::from_request(req, state).await {
31 Ok(axum::Form(value)) => Ok(Self(value)),
32 Err(rejection) => Err(rejection_to_validation(&rejection.body_text())),
33 }
34 }
35 }
36
37 /// `axum::Json<T>` whose extraction failures become [`AppError::validation`].
38 pub struct ValidatedJson<T>(pub T);
39
40 impl<T, S> FromRequest<S> for ValidatedJson<T>
41 where
42 T: DeserializeOwned,
43 S: Send + Sync,
44 {
45 type Rejection = AppError;
46
47 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
48 match axum::Json::<T>::from_request(req, state).await {
49 Ok(axum::Json(value)) => Ok(Self(value)),
50 Err(rejection) => Err(rejection_to_validation(&rejection.body_text())),
51 }
52 }
53 }
54
55 /// `axum_extra::extract::Form<T>` (repeated-field form) whose extraction failures
56 /// become [`AppError::validation`]. Used by the project wizard, whose `BasicsForm`
57 /// embeds a validating `Slug` newtype, without this, a bad slug fails extraction
58 /// with a raw rejection while a bad title (validated in-handler) got the friendly
59 /// path, the exact split the audit called out (UX-S4).
60 pub struct ValidatedHtmlForm<T>(pub T);
61
62 impl<T, S> FromRequest<S> for ValidatedHtmlForm<T>
63 where
64 T: DeserializeOwned,
65 S: Send + Sync,
66 {
67 type Rejection = AppError;
68
69 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
70 match axum_extra::extract::Form::<T>::from_request(req, state).await {
71 Ok(axum_extra::extract::Form(value)) => Ok(Self(value)),
72 Err(rejection) => Err(rejection_to_validation(&rejection.to_string())),
73 }
74 }
75 }
76
77 /// `axum::extract::Query<T>` whose extraction failures become
78 /// [`AppError::BadRequest`] instead of axum's bare `text/plain` rejection.
79 ///
80 /// Query strings are the one input a visitor edits by hand or inherits from a
81 /// stale link, so a malformed one is the most likely way to meet an error page
82 /// at all. Left as a raw `Query<T>` it answers with unlayouted framework text
83 /// (loose-wire g2-06); routed through here it gets the same branded template,
84 /// `HX-Error` header and JSON-on-API-routes treatment as every other failure.
85 ///
86 /// 400 rather than the 422 the body extractors use: nothing was submitted for
87 /// the server to process, the address itself is wrong.
88 pub struct ValidatedQuery<T>(pub T);
89
90 impl<T, S> FromRequestParts<S> for ValidatedQuery<T>
91 where
92 T: DeserializeOwned,
93 S: Send + Sync,
94 {
95 type Rejection = AppError;
96
97 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
98 match axum::extract::Query::<T>::from_request_parts(parts, state).await {
99 Ok(axum::extract::Query(value)) => Ok(Self(value)),
100 Err(rejection) => Err(rejection_to_bad_request(&rejection.body_text())),
101 }
102 }
103 }
104
105 /// `axum_extra::extract::Query<T>` (repeated-param query) with the same
106 /// treatment as [`ValidatedQuery`].
107 ///
108 /// Separate from `ValidatedQuery` for the same reason `ValidatedHtmlForm` is
109 /// separate from `ValidatedForm`: only the `axum_extra` extractor collects
110 /// `?tag=a&tag=b` into a `Vec`, and swapping one for the other silently turns
111 /// every multi-select filter on the discover page into a 400.
112 pub struct ValidatedExtraQuery<T>(pub T);
113
114 impl<T, S> FromRequestParts<S> for ValidatedExtraQuery<T>
115 where
116 T: DeserializeOwned,
117 S: Send + Sync,
118 {
119 type Rejection = AppError;
120
121 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
122 match axum_extra::extract::Query::<T>::from_request_parts(parts, state).await {
123 Ok(axum_extra::extract::Query(value)) => Ok(Self(value)),
124 Err(rejection) => Err(rejection_to_bad_request(&rejection.to_string())),
125 }
126 }
127 }
128
129 /// Same prefix-stripping as [`rejection_to_validation`], for the query-string
130 /// wording axum uses, then wrapped as a 400.
131 fn rejection_to_bad_request(detail: &str) -> AppError {
132 let msg = detail
133 .strip_prefix("Failed to deserialize query string: ")
134 .unwrap_or(detail)
135 .trim();
136 let msg = if msg.is_empty() {
137 "That link has a bad address. Please check it and try again.".to_string()
138 } else {
139 format!("That link has a bad address: {msg}")
140 };
141 AppError::BadRequest(msg)
142 }
143
144 /// Strip axum's "Failed to deserialize ...: " machinery so the user-facing message
145 /// is the underlying cause (which, for a validating newtype, is its own error
146 /// string). Falls back to a generic message when nothing useful remains.
147 fn rejection_to_validation(detail: &str) -> AppError {
148 let msg = detail
149 .strip_prefix("Failed to deserialize form body: ")
150 .or_else(|| {
151 detail.strip_prefix("Failed to deserialize the JSON body into the target type: ")
152 })
153 .unwrap_or(detail)
154 .trim();
155 let msg = if msg.is_empty() {
156 "Please check your input and try again.".to_string()
157 } else {
158 msg.to_string()
159 };
160 AppError::validation(msg)
161 }
162
163 #[cfg(test)]
164 mod tests {
165 use super::*;
166
167 #[test]
168 fn strips_form_prefix() {
169 let e = rejection_to_validation("Failed to deserialize form body: slug must be lowercase");
170 assert_eq!(e.user_message(), "slug must be lowercase");
171 }
172
173 #[test]
174 fn strips_json_prefix() {
175 let e = rejection_to_validation(
176 "Failed to deserialize the JSON body into the target type: price too high",
177 );
178 assert_eq!(e.user_message(), "price too high");
179 }
180
181 #[test]
182 fn falls_back_when_empty() {
183 let e = rejection_to_validation("");
184 assert_eq!(e.user_message(), "Please check your input and try again.");
185 }
186 }
187