Skip to main content

rust_web_server/validate/
mod.rs

1#[cfg(test)]
2mod tests;
3
4use crate::core::New;
5use crate::extract::FromRequest;
6use crate::mime_type::MimeType;
7use crate::range::Range;
8use crate::request::Request;
9use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
10
11/// A single field that failed validation.
12#[derive(Debug, Clone)]
13pub struct FieldError {
14    pub field: String,
15    pub message: String,
16}
17
18/// Collection of per-field validation errors returned by [`Validate::validate`].
19///
20/// Build one inside a manual `Validate` impl and return it as `Err(errors)` if
21/// `!errors.is_empty()`.
22#[derive(Debug)]
23pub struct ValidationErrors {
24    errors: Vec<FieldError>,
25}
26
27impl ValidationErrors {
28    pub fn new() -> Self {
29        Self { errors: Vec::new() }
30    }
31
32    /// Record a validation failure for `field` with a human-readable `message`.
33    pub fn add(&mut self, field: &str, message: &str) {
34        self.errors.push(FieldError {
35            field: field.to_string(),
36            message: message.to_string(),
37        });
38    }
39
40    pub fn is_empty(&self) -> bool {
41        self.errors.is_empty()
42    }
43
44    pub fn errors(&self) -> &[FieldError] {
45        &self.errors
46    }
47
48    /// Serialise as `{"errors":[{"field":"…","message":"…"},…]}`.
49    pub fn into_json(&self) -> String {
50        let entries: Vec<String> = self
51            .errors
52            .iter()
53            .map(|e| {
54                format!(
55                    "{{\"field\":\"{}\",\"message\":\"{}\"}}",
56                    escape(&e.field),
57                    escape(&e.message),
58                )
59            })
60            .collect();
61        format!("{{\"errors\":[{}]}}", entries.join(","))
62    }
63}
64
65fn escape(s: &str) -> String {
66    s.replace('\\', "\\\\").replace('"', "\\\"")
67}
68
69/// Types that can be validated field-by-field.
70///
71/// Implement manually or derive with `#[derive(Validate)]`
72/// (requires `features = ["macros"]`).
73///
74/// # Example — manual implementation
75///
76/// ```rust,no_run
77/// use rust_web_server::validate::{Validate, ValidationErrors};
78///
79/// struct Payload { name: String }
80///
81/// impl Validate for Payload {
82///     fn validate(&self) -> Result<(), ValidationErrors> {
83///         let mut errors = ValidationErrors::new();
84///         if self.name.is_empty() { errors.add("name", "must not be empty"); }
85///         if errors.is_empty() { Ok(()) } else { Err(errors) }
86///     }
87/// }
88/// ```
89///
90/// # Example — derive macro
91///
92/// ```ignore
93/// use rust_web_server::validate::Validate;
94///
95/// #[derive(rust_web_server::Validate)]
96/// struct CreateUser {
97///     #[validate(length(min = 1, max = 50))]
98///     name: String,
99///     #[validate(email)]
100///     email: String,
101///     #[validate(range(min = 0, max = 150))]
102///     age: u8,
103/// }
104/// ```
105///
106/// Supported validators: `email`, `required`, `url`,
107/// `length(min = N, max = N)`, `range(min = N, max = N)`.
108pub trait Validate {
109    fn validate(&self) -> Result<(), ValidationErrors>;
110}
111
112/// Wraps a `T: FromRequest + Validate`, extracting and validating in one step.
113///
114/// Returns `422 Unprocessable Entity` with a JSON error body if validation
115/// fails, or the upstream extraction error (typically 400) if extraction fails.
116///
117/// # Example
118///
119/// ```rust,no_run
120/// use rust_web_server::validate::{Validate, Validated, ValidationErrors};
121/// use rust_web_server::extract::{FromRequest, BodyText};
122/// use rust_web_server::core::New;
123/// use rust_web_server::request::Request;
124/// use rust_web_server::response::Response;
125///
126/// struct Name(String);
127///
128/// impl FromRequest for Name {
129///     fn from_request(req: &Request) -> Result<Self, Response> {
130///         let BodyText(s) = BodyText::from_request(req)?;
131///         Ok(Name(s))
132///     }
133/// }
134///
135/// impl Validate for Name {
136///     fn validate(&self) -> Result<(), ValidationErrors> {
137///         let mut errors = ValidationErrors::new();
138///         if self.0.is_empty() { errors.add("name", "must not be empty"); }
139///         if errors.is_empty() { Ok(()) } else { Err(errors) }
140///     }
141/// }
142///
143/// fn handle(req: &Request) -> Response {
144///     let Validated(name) = match Validated::<Name>::from_request(req) {
145///         Ok(v)    => v,
146///         Err(res) => return res,  // 400 or 422
147///     };
148///     Response::new()
149/// }
150/// ```
151pub struct Validated<T>(pub T);
152
153impl<T: FromRequest + Validate> FromRequest for Validated<T> {
154    fn from_request(request: &Request) -> Result<Self, Response> {
155        let value = T::from_request(request)?;
156        value.validate().map_err(validation_error_response)?;
157        Ok(Validated(value))
158    }
159}
160
161fn validation_error_response(errors: ValidationErrors) -> Response {
162    let json = errors.into_json();
163    let cr = Range::get_content_range(json.into_bytes(), MimeType::APPLICATION_JSON.to_string());
164    let mut response = Response::new();
165    response.status_code = *STATUS_CODE_REASON_PHRASE.n422_unprocessable_entity.status_code;
166    response.reason_phrase = STATUS_CODE_REASON_PHRASE
167        .n422_unprocessable_entity
168        .reason_phrase
169        .to_string();
170    response.content_range_list = vec![cr];
171    response
172}
173
174/// Returns `true` if `s` is a plausible email address.
175///
176/// Checks for a non-empty local part, exactly one `@`, and a domain that
177/// contains at least one `.` and does not start or end with `.`.
178pub fn is_email(s: &str) -> bool {
179    let mut parts = s.splitn(2, '@');
180    let local = parts.next().unwrap_or("");
181    let domain = match parts.next() {
182        Some(d) => d,
183        None => return false,
184    };
185    !local.is_empty()
186        && domain.len() >= 3
187        && domain.contains('.')
188        && !domain.starts_with('.')
189        && !domain.ends_with('.')
190}
191
192/// Returns `true` if `s` starts with `http://` or `https://`.
193pub fn is_url(s: &str) -> bool {
194    s.starts_with("http://") || s.starts_with("https://")
195}