umbral_core/validate.rs
1//! Request-body validation (gaps3 #29 item 4).
2//!
3//! `#[umbral(trim, lowercase, max_length = N, email, ...)]` has always worked on a
4//! `Model` — but a great many request bodies are not models. They are DTOs: a
5//! `#[derive(Deserialize)]` struct that gets checked, then turned into something else.
6//! Until now those had no story at all, so every handler re-implemented the same four
7//! rules by hand.
8//!
9//! ```ignore
10//! #[derive(Deserialize, Validate)]
11//! struct CreateGoal {
12//! #[umbral(trim, min_length = 1, max_length = 80)]
13//! scorer: String,
14//! #[umbral(choices = ["home", "away"])]
15//! side: String,
16//! #[umbral(min = 0, max = 120)]
17//! minute: i64,
18//! }
19//!
20//! async fn create_goal(Valid(body): Valid<CreateGoal>) -> impl IntoResponse {
21//! // `body` is normalised AND checked. There is no path into this function
22//! // where it is not.
23//! }
24//! ```
25//!
26//! Two properties are doing the work here.
27//!
28//! **The vocabulary is the one you already know.** These are the *same* attribute
29//! names, with the same meanings, as on a `Model` — and `email` / `url` / `slug` call
30//! the very same [`validate_text_format`](crate::orm::validators::validate_text_format)
31//! the ORM's write path calls. A string a DTO accepts is a string the model accepts.
32//! Two validators that "both check emails" is how you end up with a row your own API
33//! cannot round-trip.
34//!
35//! **The gate is in the signature.** `Valid<T>` is an extractor, so a handler that
36//! forgot to validate does not compile into existence. A `validate()` helper you must
37//! remember to call is a gate you can forget.
38
39use serde::de::DeserializeOwned;
40
41pub use crate::forms::ValidationErrors;
42
43/// Normalise a value in place, then check it.
44///
45/// Derived with `#[derive(Validate)]`. Implement by hand only when the rules cannot be
46/// spelled as attributes.
47///
48/// `&mut self` is not an accident: `trim` and `lowercase` **rewrite** the value, and a
49/// validator that could only say no would leave every caller to do the normalising
50/// itself — which is the boilerplate this exists to delete.
51pub trait Validate: Sized {
52 /// Rewrite this value into its normalised form and return every rule it breaks.
53 ///
54 /// Errors accumulate: a body with three bad fields reports all three, because
55 /// making a user fix one mistake per round-trip is its own kind of bug.
56 fn validate(&mut self) -> Result<(), ValidationErrors>;
57}
58
59/// Extractor: deserialize a JSON body, normalise it, validate it — or reject with the
60/// same structured 400 the REST plugin emits for a model write.
61///
62/// ```ignore
63/// async fn handler(Valid(body): Valid<CreateGoal>) -> impl IntoResponse { ... }
64/// ```
65#[derive(Debug, Clone, Copy, Default)]
66pub struct Valid<T>(pub T);
67
68impl<T> std::ops::Deref for Valid<T> {
69 type Target = T;
70 fn deref(&self) -> &T {
71 &self.0
72 }
73}
74
75/// Why a [`Valid<T>`] extraction failed.
76#[derive(Debug)]
77pub enum ValidRejection {
78 /// The body was not valid JSON, or did not fit the struct.
79 Malformed(String),
80 /// The body parsed, but broke one or more rules.
81 Invalid(ValidationErrors),
82}
83
84impl axum::response::IntoResponse for ValidRejection {
85 fn into_response(self) -> axum::response::Response {
86 use axum::Json;
87 use http::StatusCode;
88
89 match self {
90 // A body that will not parse has no field-level shape to report — there is
91 // no "which field" when the JSON itself is broken.
92 ValidRejection::Malformed(msg) => (
93 StatusCode::BAD_REQUEST,
94 Json(serde_json::json!({ "code": "malformed_body", "error": msg })),
95 )
96 .into_response(),
97
98 // The shape below is byte-for-byte the one `umbral-rest` returns when a
99 // model write fails validation: field errors flattened to the top level,
100 // `non_field_errors` alongside, a stable `code`. A client should not have
101 // to care whether the 400 it just got came from a viewset or a hand-written
102 // handler — one API, one error format.
103 ValidRejection::Invalid(errs) => {
104 let mut body = serde_json::Map::new();
105 body.insert("code".into(), serde_json::json!("validation_error"));
106 for (field, messages) in errs.fields {
107 body.insert(field, serde_json::json!(messages));
108 }
109 if !errs.non_field.is_empty() {
110 body.insert("non_field_errors".into(), serde_json::json!(errs.non_field));
111 }
112 (
113 StatusCode::BAD_REQUEST,
114 Json(serde_json::Value::Object(body)),
115 )
116 .into_response()
117 }
118 }
119 }
120}
121
122impl<T, S> axum::extract::FromRequest<S> for Valid<T>
123where
124 T: DeserializeOwned + Validate,
125 S: Send + Sync,
126{
127 type Rejection = ValidRejection;
128
129 async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
130 let axum::Json(mut value) = axum::Json::<T>::from_request(req, state)
131 .await
132 .map_err(|e| ValidRejection::Malformed(e.body_text()))?;
133 value.validate().map_err(ValidRejection::Invalid)?;
134 Ok(Valid(value))
135 }
136}
137
138// ---------------------------------------------------------------------------
139// Rule helpers. The derive expands to calls into these rather than inlining the
140// logic, so a rule's behaviour lives in ONE place and the generated code stays
141// small enough to read in a macro-expansion dump.
142// ---------------------------------------------------------------------------
143
144/// `#[umbral(min_length = N)]` — counts CHARACTERS, not bytes. `"é"` is one character
145/// and two bytes; a byte-length limit would reject a name a user can legitimately have.
146pub fn check_min_length(errs: &mut ValidationErrors, field: &str, value: &str, n: usize) {
147 if value.chars().count() < n {
148 errs.add(
149 field,
150 if n == 1 {
151 "This field cannot be blank.".to_string()
152 } else {
153 format!("Must be at least {n} characters.")
154 },
155 );
156 }
157}
158
159/// `#[umbral(max_length = N)]` — rejects rather than truncating. Silently cutting a
160/// user's input to length is data loss that looks like success.
161pub fn check_max_length(errs: &mut ValidationErrors, field: &str, value: &str, n: usize) {
162 let len = value.chars().count();
163 if len > n {
164 errs.add(
165 field,
166 format!("Must be at most {n} characters (got {len})."),
167 );
168 }
169}
170
171/// `#[umbral(email)]` / `#[umbral(url)]` / `#[umbral(slug)]` — delegates to the ORM's
172/// validator so a DTO and a model agree on what a valid email is.
173pub fn check_text_format(errs: &mut ValidationErrors, field: &str, value: &str, format: &str) {
174 if let Err(e) = crate::orm::validators::validate_text_format(format, value) {
175 errs.add(field, e.to_string());
176 }
177}
178
179/// `#[umbral(choices = ["home", "away"])]` — an enum-of-strings, without the enum.
180pub fn check_choices(errs: &mut ValidationErrors, field: &str, value: &str, allowed: &[&str]) {
181 if !allowed.contains(&value) {
182 errs.add(field, format!("Must be one of: {}.", allowed.join(", ")));
183 }
184}
185
186/// `#[umbral(min = N)]` / `#[umbral(max = N)]` on a numeric field.
187///
188/// Compares as `f64` so one helper covers every integer and float width. The bounds a
189/// request body checks (an age, a minute of a match, a page size) are nowhere near the
190/// magnitude where f64 loses integer precision.
191pub fn check_min(errs: &mut ValidationErrors, field: &str, value: f64, n: f64) {
192 if value < n {
193 errs.add(field, format!("Must be at least {n}."));
194 }
195}
196
197/// See [`check_min`].
198pub fn check_max(errs: &mut ValidationErrors, field: &str, value: f64, n: f64) {
199 if value > n {
200 errs.add(field, format!("Must be at most {n}."));
201 }
202}