Skip to main content

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}