Skip to main content

rws_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/// # extern crate rws_server as rust_web_server;
78/// use rust_web_server::validate::{Validate, ValidationErrors};
79///
80/// struct Payload { name: String }
81///
82/// impl Validate for Payload {
83///     fn validate(&self) -> Result<(), ValidationErrors> {
84///         let mut errors = ValidationErrors::new();
85///         if self.name.is_empty() { errors.add("name", "must not be empty"); }
86///         if errors.is_empty() { Ok(()) } else { Err(errors) }
87///     }
88/// }
89/// ```
90///
91/// # Example — derive macro
92///
93/// ```ignore
94/// use rust_web_server::validate::Validate;
95///
96/// #[derive(rust_web_server::Validate)]
97/// struct CreateUser {
98///     #[validate(length(min = 1, max = 50))]
99///     name: String,
100///     #[validate(email)]
101///     email: String,
102///     #[validate(range(min = 0, max = 150))]
103///     age: u8,
104/// }
105/// ```
106///
107/// Supported validators: `email`, `required`, `url`,
108/// `length(min = N, max = N)`, `range(min = N, max = N)`.
109pub trait Validate {
110    fn validate(&self) -> Result<(), ValidationErrors>;
111}
112
113/// Wraps a `T: FromRequest + Validate`, extracting and validating in one step.
114///
115/// Returns `422 Unprocessable Entity` with a JSON error body if validation
116/// fails, or the upstream extraction error (typically 400) if extraction fails.
117///
118/// # Example
119///
120/// ```rust,no_run
121/// # extern crate rws_server as rust_web_server;
122/// use rust_web_server::validate::{Validate, Validated, ValidationErrors};
123/// use rust_web_server::extract::{FromRequest, BodyText};
124/// use rust_web_server::core::New;
125/// use rust_web_server::request::Request;
126/// use rust_web_server::response::Response;
127///
128/// struct Name(String);
129///
130/// impl FromRequest for Name {
131///     fn from_request(req: &Request) -> Result<Self, Response> {
132///         let BodyText(s) = BodyText::from_request(req)?;
133///         Ok(Name(s))
134///     }
135/// }
136///
137/// impl Validate for Name {
138///     fn validate(&self) -> Result<(), ValidationErrors> {
139///         let mut errors = ValidationErrors::new();
140///         if self.0.is_empty() { errors.add("name", "must not be empty"); }
141///         if errors.is_empty() { Ok(()) } else { Err(errors) }
142///     }
143/// }
144///
145/// fn handle(req: &Request) -> Response {
146///     let Validated(name) = match Validated::<Name>::from_request(req) {
147///         Ok(v)    => v,
148///         Err(res) => return res,  // 400 or 422
149///     };
150///     Response::new()
151/// }
152/// ```
153pub struct Validated<T>(pub T);
154
155impl<T: FromRequest + Validate> FromRequest for Validated<T> {
156    fn from_request(request: &Request) -> Result<Self, Response> {
157        let value = T::from_request(request)?;
158        value.validate().map_err(validation_error_response)?;
159        Ok(Validated(value))
160    }
161}
162
163fn validation_error_response(errors: ValidationErrors) -> Response {
164    let json = errors.into_json();
165    let cr = Range::get_content_range(json.into_bytes(), MimeType::APPLICATION_JSON.to_string());
166    let mut response = Response::new();
167    response.status_code = *STATUS_CODE_REASON_PHRASE.n422_unprocessable_entity.status_code;
168    response.reason_phrase = STATUS_CODE_REASON_PHRASE
169        .n422_unprocessable_entity
170        .reason_phrase
171        .to_string();
172    response.content_range_list = vec![cr];
173    response
174}
175
176/// Returns `true` if `s` is a plausible email address.
177///
178/// Checks for a non-empty local part, exactly one `@`, and a domain that
179/// contains at least one `.` and does not start or end with `.`.
180pub fn is_email(s: &str) -> bool {
181    let mut parts = s.splitn(2, '@');
182    let local = parts.next().unwrap_or("");
183    let domain = match parts.next() {
184        Some(d) => d,
185        None => return false,
186    };
187    !local.is_empty()
188        && domain.len() >= 3
189        && domain.contains('.')
190        && !domain.starts_with('.')
191        && !domain.ends_with('.')
192}
193
194/// Returns `true` if `s` starts with `http://` or `https://`.
195pub fn is_url(s: &str) -> bool {
196    s.starts_with("http://") || s.starts_with("https://")
197}