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
39crate::impl_meta_methods!(Url);
40
41impl Validator for Url {
42    fn meta(&self) -> &Meta {
43        &self.meta
44    }
45
46    fn meta_mut(&mut self) -> &mut Meta {
47        &mut self.meta
48    }
49
50    fn check(&self, value: &mut Value) -> Result<(), Error> {
51        let text = match value {
52            Value::String(text) => text,
53            other => {
54                return Err(Error::new(ErrorKind::Type {
55                    expected: ValueType::String,
56                    found: other.type_name(),
57                }));
58            }
59        };
60
61        let parsed = match url::Url::parse(text) {
62            Ok(parsed) => parsed,
63            Err(_) => return Err(Error::new(ErrorKind::Format { expected: "url" })),
64        };
65
66        if !self.schemes.is_empty() {
67            let mut allowed = false;
68            for scheme in &self.schemes {
69                if scheme.eq_ignore_ascii_case(parsed.scheme()) {
70                    allowed = true;
71                    break;
72                }
73            }
74            if !allowed {
75                return Err(Error::new(ErrorKind::Format {
76                    expected: "url with an allowed scheme",
77                }));
78            }
79        }
80
81        if self.require_host && parsed.host().is_none() {
82            return Err(Error::new(ErrorKind::Format {
83                expected: "url with a host",
84            }));
85        }
86
87        Ok(())
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn string(text: &str) -> Value {
96        Value::String(text.to_string())
97    }
98
99    #[test]
100    fn accepts_url() {
101        assert!(
102            Url::new()
103                .validate(&mut string("https://example.com/x"))
104                .is_ok()
105        );
106        assert!(Url::new().validate(&mut string("not a url")).is_err());
107    }
108
109    #[test]
110    fn restricts_scheme_and_host() {
111        let validator = Url::new().schemes(["https"]).require_host();
112        assert!(
113            validator
114                .validate(&mut string("https://example.com"))
115                .is_ok()
116        );
117        assert!(
118            validator
119                .validate(&mut string("http://example.com"))
120                .is_err()
121        );
122        assert!(validator.validate(&mut string("mailto:a@b.com")).is_err());
123    }
124}