Skip to main content

siderite_validation/types/
url.rs

1//! URL newtypes: any-scheme [`Url`] and http(s)-only [`HttpUrl`].
2
3use serde::{Deserialize, Serialize};
4
5use crate::context::ValidationContext;
6use crate::dump::Dump;
7use crate::error::FieldError;
8use crate::rules;
9use crate::schema::{Schema, SchemaObject, SchemaRegistry};
10use crate::validate::Validate;
11use serde_json::Value;
12
13/// An absolute URL of any scheme.
14///
15/// Unlike Pydantic `AnyUrl`, this does not restrict allowed schemes, host
16/// presence, or TLD — it accepts any value [`url::Url::parse`] accepts.
17#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(try_from = "String", into = "String")]
19pub struct Url(::url::Url);
20
21impl Url {
22    /// Parse `value` as an absolute URL.
23    pub fn new(value: impl AsRef<str>) -> Result<Self, FieldError> {
24        rules::parse_url(value.as_ref()).map(Self)
25    }
26
27    /// The URL as a string slice.
28    pub fn as_str(&self) -> &str {
29        self.0.as_str()
30    }
31}
32
33crate::types::impl_wrapper!(Url => ::url::Url);
34
35impl TryFrom<String> for Url {
36    type Error = FieldError;
37
38    fn try_from(value: String) -> Result<Self, Self::Error> {
39        Self::new(value)
40    }
41}
42
43impl TryFrom<&str> for Url {
44    type Error = FieldError;
45
46    fn try_from(value: &str) -> Result<Self, Self::Error> {
47        Self::new(value)
48    }
49}
50
51impl From<Url> for String {
52    fn from(value: Url) -> Self {
53        value.0.to_string()
54    }
55}
56
57impl AsRef<str> for Url {
58    fn as_ref(&self) -> &str {
59        self.0.as_str()
60    }
61}
62
63impl Validate for Url {
64    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
65        if super::prepare_as_string(input, ctx) {
66            ctx.check(Self::new(input.as_str().unwrap_or_default()).map(|_| ()));
67        }
68    }
69}
70
71impl Schema for Url {
72    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
73        SchemaObject::of_type("string").with("format", "uri")
74    }
75}
76
77impl Dump for Url {}
78
79/// An absolute `http` or `https` URL.
80///
81/// Unlike Pydantic `HttpUrl`, this only checks the scheme. Host, TLD, and
82/// maximum length are not enforced.
83#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
84#[serde(try_from = "String", into = "String")]
85pub struct HttpUrl(::url::Url);
86
87fn is_http_scheme(url: &::url::Url) -> bool {
88    matches!(url.scheme(), "http" | "https")
89}
90
91impl HttpUrl {
92    /// Parse `value` as an `http` or `https` URL.
93    pub fn new(value: impl AsRef<str>) -> Result<Self, FieldError> {
94        let url = rules::parse_url(value.as_ref())?;
95        if !is_http_scheme(&url) {
96            return Err(FieldError::new(
97                "url_scheme",
98                "URL scheme must be http or https",
99            ));
100        }
101        Ok(Self(url))
102    }
103
104    /// The URL as a string slice.
105    pub fn as_str(&self) -> &str {
106        self.0.as_str()
107    }
108}
109
110crate::types::impl_wrapper!(HttpUrl => ::url::Url);
111
112impl TryFrom<String> for HttpUrl {
113    type Error = FieldError;
114
115    fn try_from(value: String) -> Result<Self, Self::Error> {
116        Self::new(value)
117    }
118}
119
120impl TryFrom<&str> for HttpUrl {
121    type Error = FieldError;
122
123    fn try_from(value: &str) -> Result<Self, Self::Error> {
124        Self::new(value)
125    }
126}
127
128impl From<HttpUrl> for String {
129    fn from(value: HttpUrl) -> Self {
130        value.0.to_string()
131    }
132}
133
134impl AsRef<str> for HttpUrl {
135    fn as_ref(&self) -> &str {
136        self.0.as_str()
137    }
138}
139
140impl Validate for HttpUrl {
141    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
142        if super::prepare_as_string(input, ctx) {
143            ctx.check(Self::new(input.as_str().unwrap_or_default()).map(|_| ()));
144        }
145    }
146}
147
148impl Schema for HttpUrl {
149    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
150        SchemaObject::of_type("string").with("format", "uri")
151    }
152}
153
154impl Dump for HttpUrl {}
155
156#[cfg(test)]
157mod tests {
158    #![allow(clippy::unwrap_used)]
159
160    use super::*;
161    use crate::context::ValidationContext;
162    use crate::dump::{Dump, DumpOptions};
163    use crate::schema::schema_for;
164    use crate::types::prepare_codes;
165    use serde_json::json;
166
167    #[test]
168    fn url_construction() {
169        let url = Url::new("https://example.com/x").unwrap();
170        assert_eq!(url.as_str(), "https://example.com/x");
171        assert_eq!(url.scheme(), "https");
172        assert!(Url::new("ftp://files.example.com").is_ok());
173        assert_eq!(Url::new("not a url").unwrap_err().code, "url_parsing");
174        assert_eq!(Url::new("example.com").unwrap_err().code, "url_parsing");
175    }
176
177    #[test]
178    fn http_url_rejects_other_schemes() {
179        assert!(HttpUrl::new("http://example.com").is_ok());
180        assert!(HttpUrl::new("https://example.com").is_ok());
181        assert_eq!(
182            HttpUrl::new("ftp://example.com").unwrap_err().code,
183            "url_scheme"
184        );
185        assert_eq!(HttpUrl::new("nope").unwrap_err().code, "url_parsing");
186    }
187
188    #[test]
189    fn url_serde_round_trip_and_reject() {
190        let url = Url::new("https://example.com").unwrap();
191        let encoded = serde_json::to_string(&url).unwrap();
192        assert_eq!(encoded, "\"https://example.com/\"");
193        let round: Url = serde_json::from_str(&encoded).unwrap();
194        assert_eq!(round.as_str(), "https://example.com/");
195        assert!(serde_json::from_str::<Url>("\"not a url\"").is_err());
196        assert!(serde_json::from_str::<HttpUrl>("\"ftp://example.com\"").is_err());
197    }
198
199    #[test]
200    fn url_prepare_lax_and_strict() {
201        let (out, codes) =
202            prepare_codes::<Url>(json!("https://example.com"), ValidationContext::new());
203        assert_eq!(codes, Vec::<String>::new());
204        assert_eq!(out, json!("https://example.com"));
205        assert_eq!(
206            prepare_codes::<Url>(json!(1), ValidationContext::new()).1,
207            ["string_type"]
208        );
209        assert_eq!(
210            prepare_codes::<Url>(json!(1), ValidationContext::new().with_strict(true)).1,
211            ["string_type"]
212        );
213        assert_eq!(
214            prepare_codes::<Url>(json!("nope"), ValidationContext::new()).1,
215            ["url_parsing"]
216        );
217        assert_eq!(
218            prepare_codes::<HttpUrl>(json!("ftp://x.com"), ValidationContext::new()).1,
219            ["url_scheme"]
220        );
221    }
222
223    #[test]
224    fn url_schema_dump_and_parse_value() {
225        assert_eq!(
226            schema_for::<Url>().0.into_value(),
227            json!({"type": "string", "format": "uri"})
228        );
229        assert_eq!(
230            schema_for::<HttpUrl>().0.into_value(),
231            json!({"type": "string", "format": "uri"})
232        );
233        let url = Url::new("https://example.com").unwrap();
234        assert_eq!(
235            url.dump(&DumpOptions::new()).unwrap(),
236            json!("https://example.com/")
237        );
238        let parsed =
239            crate::parse_value::<HttpUrl>(json!("https://example.com/a"), ValidationContext::new())
240                .unwrap();
241        assert_eq!(parsed.as_str(), "https://example.com/a");
242        let err = crate::parse_value::<Url>(json!("nope"), ValidationContext::new()).unwrap_err();
243        assert_eq!(err.errors[0].code, "url_parsing");
244    }
245}