Skip to main content

tanzim_validate/
url.rs

1use crate::error::{Error, ErrorKind};
2use crate::{Meta, Validator};
3use tanzim_value::{Value, ValueType};
4
5/// (`url` feature) Accepts a URL, optionally restricting the scheme and requiring a host.
6#[derive(Debug, Clone, Default)]
7pub struct Url {
8    meta: Meta,
9    schemes: Vec<String>,
10    require_host: bool,
11}
12
13impl Url {
14    /// Attach human-facing metadata (name, description, examples, default, output conversion).
15    pub fn with_meta(mut self, meta: Meta) -> Self {
16        self.meta = meta;
17        self
18    }
19
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Restrict to the given schemes, e.g. `Url::new().schemes(["http", "https"])`.
25    pub fn schemes(mut self, schemes: impl IntoIterator<Item = impl Into<String>>) -> Self {
26        for scheme in schemes {
27            self.schemes.push(scheme.into());
28        }
29        self
30    }
31
32    /// Require the URL to have a host component.
33    pub fn require_host(mut self) -> Self {
34        self.require_host = true;
35        self
36    }
37}
38
39impl Validator for Url {
40    fn meta(&self) -> &Meta {
41        &self.meta
42    }
43
44    fn meta_mut(&mut self) -> &mut Meta {
45        &mut self.meta
46    }
47
48    fn check(&self, value: &mut Value) -> Result<(), Error> {
49        let text = match value {
50            Value::String(text) => text,
51            other => {
52                return Err(Error::new(ErrorKind::Type {
53                    expected: ValueType::String,
54                    found: other.type_name(),
55                }));
56            }
57        };
58
59        let parsed = match url::Url::parse(text) {
60            Ok(parsed) => parsed,
61            Err(_) => return Err(Error::new(ErrorKind::Format { expected: "url" })),
62        };
63
64        if !self.schemes.is_empty() {
65            let mut allowed = false;
66            for scheme in &self.schemes {
67                if scheme.eq_ignore_ascii_case(parsed.scheme()) {
68                    allowed = true;
69                    break;
70                }
71            }
72            if !allowed {
73                return Err(Error::new(ErrorKind::Format {
74                    expected: "url with an allowed scheme",
75                }));
76            }
77        }
78
79        if self.require_host && parsed.host().is_none() {
80            return Err(Error::new(ErrorKind::Format {
81                expected: "url with a host",
82            }));
83        }
84
85        Ok(())
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn string(text: &str) -> Value {
94        Value::String(text.to_string())
95    }
96
97    #[test]
98    fn accepts_url() {
99        assert!(
100            Url::new()
101                .validate(&mut string("https://example.com/x"))
102                .is_ok()
103        );
104        assert!(Url::new().validate(&mut string("not a url")).is_err());
105    }
106
107    #[test]
108    fn restricts_scheme_and_host() {
109        let validator = Url::new().schemes(["https"]).require_host();
110        assert!(
111            validator
112                .validate(&mut string("https://example.com"))
113                .is_ok()
114        );
115        assert!(
116            validator
117                .validate(&mut string("http://example.com"))
118                .is_err()
119        );
120        assert!(validator.validate(&mut string("mailto:a@b.com")).is_err());
121    }
122}