stilltypes/
url.rs

1//! URL validation types.
2//!
3//! Provides RFC 3986 compliant URL validation using the `url` crate (WHATWG URL Standard).
4//!
5//! This module demonstrates stillwater's predicate composition using the `And` combinator:
6//! - `Url` - any valid RFC 3986 URL
7//! - `HttpUrl` - composed as `And<ValidUrl, HttpScheme>`
8//! - `SecureUrl` - composed as `And<ValidUrl, HttpsOnly>`
9//!
10//! # Example
11//!
12//! ```
13//! use stilltypes::url::{Url, HttpUrl, SecureUrl};
14//!
15//! // Any valid URL
16//! let url = Url::new("https://example.com".to_string());
17//! assert!(url.is_ok());
18//!
19//! // HTTP or HTTPS only
20//! let http = HttpUrl::new("http://example.com".to_string());
21//! assert!(http.is_ok());
22//!
23//! // HTTPS only (secure)
24//! let https = SecureUrl::new("https://example.com".to_string());
25//! assert!(https.is_ok());
26//!
27//! let insecure = SecureUrl::new("http://example.com".to_string());
28//! assert!(insecure.is_err());
29//! ```
30
31use crate::error::{DomainError, DomainErrorKind};
32use stillwater::refined::{And, Predicate, Refined};
33use url::Url as UrlParser;
34
35/// Any valid RFC 3986 URL.
36///
37/// Uses the `url` crate for parsing, which implements the WHATWG URL Standard.
38///
39/// # Example
40///
41/// ```
42/// use stilltypes::url::Url;
43///
44/// let url = Url::new("https://example.com/path".to_string());
45/// assert!(url.is_ok());
46///
47/// let invalid = Url::new("not a url".to_string());
48/// assert!(invalid.is_err());
49/// ```
50#[derive(Debug, Clone, Copy, Default)]
51pub struct ValidUrl;
52
53impl Predicate<String> for ValidUrl {
54    type Error = DomainError;
55
56    fn check(value: &String) -> Result<(), Self::Error> {
57        UrlParser::parse(value)
58            .map(|_| ())
59            .map_err(|_| DomainError {
60                format_name: "URL",
61                value: value.clone(),
62                reason: DomainErrorKind::InvalidFormat {
63                    expected: "scheme://host/path",
64                },
65                example: "https://example.com",
66            })
67    }
68
69    fn description() -> &'static str {
70        "RFC 3986 URL"
71    }
72}
73
74/// URL scheme must be http or https.
75///
76/// This predicate validates only the scheme, not the overall URL validity.
77/// For a complete HTTP URL, use `HttpUrl` which combines `ValidUrl` and `HttpScheme`.
78///
79/// # Example
80///
81/// ```
82/// use stilltypes::url::HttpUrl;
83///
84/// let http = HttpUrl::new("http://example.com".to_string());
85/// assert!(http.is_ok());
86///
87/// let ftp = HttpUrl::new("ftp://example.com".to_string());
88/// assert!(ftp.is_err());
89/// ```
90#[derive(Debug, Clone, Copy, Default)]
91pub struct HttpScheme;
92
93impl Predicate<String> for HttpScheme {
94    type Error = DomainError;
95
96    fn check(value: &String) -> Result<(), Self::Error> {
97        let parsed = UrlParser::parse(value).map_err(|_| DomainError {
98            format_name: "HTTP URL",
99            value: value.clone(),
100            reason: DomainErrorKind::InvalidFormat {
101                expected: "valid URL",
102            },
103            example: "https://example.com",
104        })?;
105
106        match parsed.scheme() {
107            "http" | "https" => Ok(()),
108            scheme => Err(DomainError {
109                format_name: "HTTP URL",
110                value: value.clone(),
111                reason: DomainErrorKind::InvalidComponent {
112                    component: "scheme",
113                    reason: format!("expected http or https, got {}", scheme),
114                },
115                example: "https://example.com",
116            }),
117        }
118    }
119
120    fn description() -> &'static str {
121        "HTTP or HTTPS scheme"
122    }
123}
124
125/// URL scheme must be https (secure).
126///
127/// This predicate validates only the scheme, not the overall URL validity.
128/// For a complete secure URL, use `SecureUrl` which combines `ValidUrl` and `HttpsOnly`.
129///
130/// # Example
131///
132/// ```
133/// use stilltypes::url::SecureUrl;
134///
135/// let https = SecureUrl::new("https://example.com".to_string());
136/// assert!(https.is_ok());
137///
138/// let http = SecureUrl::new("http://example.com".to_string());
139/// assert!(http.is_err());
140/// ```
141#[derive(Debug, Clone, Copy, Default)]
142pub struct HttpsOnly;
143
144impl Predicate<String> for HttpsOnly {
145    type Error = DomainError;
146
147    fn check(value: &String) -> Result<(), Self::Error> {
148        let parsed = UrlParser::parse(value).map_err(|_| DomainError {
149            format_name: "HTTPS URL",
150            value: value.clone(),
151            reason: DomainErrorKind::InvalidFormat {
152                expected: "valid URL",
153            },
154            example: "https://example.com",
155        })?;
156
157        if parsed.scheme() == "https" {
158            Ok(())
159        } else {
160            Err(DomainError {
161                format_name: "HTTPS URL",
162                value: value.clone(),
163                reason: DomainErrorKind::InvalidComponent {
164                    component: "scheme",
165                    reason: format!("expected https, got {}", parsed.scheme()),
166                },
167                example: "https://example.com",
168            })
169        }
170    }
171
172    fn description() -> &'static str {
173        "HTTPS scheme only"
174    }
175}
176
177/// Any valid URL (RFC 3986).
178///
179/// A `String` that has been validated to be a properly formatted URL
180/// according to RFC 3986 (via the WHATWG URL Standard).
181///
182/// # Example
183///
184/// ```
185/// use stilltypes::url::Url;
186///
187/// let url = Url::new("https://example.com/path?query=value".to_string()).unwrap();
188/// assert_eq!(url.get(), "https://example.com/path?query=value");
189/// ```
190pub type Url = Refined<String, ValidUrl>;
191
192/// HTTP or HTTPS URL.
193///
194/// Composed using stillwater's `And` combinator to validate both
195/// URL structure and scheme.
196///
197/// # Example
198///
199/// ```
200/// use stilltypes::url::HttpUrl;
201///
202/// let http = HttpUrl::new("http://example.com".to_string()).unwrap();
203/// let https = HttpUrl::new("https://example.com".to_string()).unwrap();
204///
205/// // FTP is rejected
206/// let ftp = HttpUrl::new("ftp://files.example.com".to_string());
207/// assert!(ftp.is_err());
208/// ```
209pub type HttpUrl = Refined<String, And<ValidUrl, HttpScheme>>;
210
211/// HTTPS-only URL (secure).
212///
213/// Composed using stillwater's `And` combinator to validate both
214/// URL structure and secure scheme.
215///
216/// # Example
217///
218/// ```
219/// use stilltypes::url::SecureUrl;
220///
221/// let secure = SecureUrl::new("https://api.example.com".to_string()).unwrap();
222///
223/// // HTTP is rejected
224/// let insecure = SecureUrl::new("http://example.com".to_string());
225/// assert!(insecure.is_err());
226/// ```
227pub type SecureUrl = Refined<String, And<ValidUrl, HttpsOnly>>;
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    // ValidUrl tests
234    #[test]
235    fn valid_https_url() {
236        assert!(Url::new("https://example.com".to_string()).is_ok());
237    }
238
239    #[test]
240    fn valid_http_url() {
241        assert!(Url::new("http://example.com".to_string()).is_ok());
242    }
243
244    #[test]
245    fn valid_with_path() {
246        assert!(Url::new("https://example.com/path/to/resource".to_string()).is_ok());
247    }
248
249    #[test]
250    fn valid_with_query() {
251        assert!(Url::new("https://example.com?foo=bar&baz=qux".to_string()).is_ok());
252    }
253
254    #[test]
255    fn valid_with_fragment() {
256        assert!(Url::new("https://example.com#section".to_string()).is_ok());
257    }
258
259    #[test]
260    fn valid_with_port() {
261        assert!(Url::new("https://example.com:8080".to_string()).is_ok());
262    }
263
264    #[test]
265    fn valid_ftp_url() {
266        // ValidUrl accepts any scheme
267        assert!(Url::new("ftp://files.example.com".to_string()).is_ok());
268    }
269
270    #[test]
271    fn invalid_missing_scheme() {
272        assert!(Url::new("example.com".to_string()).is_err());
273    }
274
275    #[test]
276    fn invalid_malformed() {
277        assert!(Url::new("not a url at all".to_string()).is_err());
278    }
279
280    #[test]
281    fn valid_url_description() {
282        assert_eq!(ValidUrl::description(), "RFC 3986 URL");
283    }
284
285    // HttpUrl tests
286    #[test]
287    fn http_url_accepts_http() {
288        assert!(HttpUrl::new("http://example.com".to_string()).is_ok());
289    }
290
291    #[test]
292    fn http_url_accepts_https() {
293        assert!(HttpUrl::new("https://example.com".to_string()).is_ok());
294    }
295
296    #[test]
297    fn http_url_rejects_ftp() {
298        let result = HttpUrl::new("ftp://example.com".to_string());
299        assert!(result.is_err());
300        // HttpUrl uses And combinator which returns AndError
301        // FTP passes ValidUrl but fails HttpScheme, so it's AndError::Second
302        let err = result.unwrap_err();
303        match err {
304            stillwater::refined::AndError::Second(domain_err) => {
305                assert!(matches!(
306                    domain_err.reason,
307                    DomainErrorKind::InvalidComponent { .. }
308                ));
309            }
310            _ => panic!("Expected AndError::Second for scheme rejection"),
311        }
312    }
313
314    #[test]
315    fn http_url_rejects_file() {
316        assert!(HttpUrl::new("file:///path/to/file".to_string()).is_err());
317    }
318
319    #[test]
320    fn http_scheme_description() {
321        assert_eq!(HttpScheme::description(), "HTTP or HTTPS scheme");
322    }
323
324    // SecureUrl tests
325    #[test]
326    fn secure_url_accepts_https() {
327        assert!(SecureUrl::new("https://example.com".to_string()).is_ok());
328    }
329
330    #[test]
331    fn secure_url_rejects_http() {
332        let result = SecureUrl::new("http://example.com".to_string());
333        assert!(result.is_err());
334        // SecureUrl uses And combinator which returns AndError
335        // HTTP passes ValidUrl but fails HttpsOnly, so it's AndError::Second
336        let err = result.unwrap_err();
337        match err {
338            stillwater::refined::AndError::Second(domain_err) => {
339                assert!(matches!(
340                    domain_err.reason,
341                    DomainErrorKind::InvalidComponent { .. }
342                ));
343            }
344            _ => panic!("Expected AndError::Second for scheme rejection"),
345        }
346    }
347
348    #[test]
349    fn https_only_description() {
350        assert_eq!(HttpsOnly::description(), "HTTPS scheme only");
351    }
352
353    // Test standalone predicates with malformed URLs
354    // (normally unreachable when composed with ValidUrl via And)
355    #[test]
356    fn https_only_standalone_rejects_malformed() {
357        let result = HttpsOnly::check(&"not a url".to_string());
358        assert!(result.is_err());
359        let err = result.unwrap_err();
360        assert_eq!(err.format_name, "HTTPS URL");
361        assert!(matches!(err.reason, DomainErrorKind::InvalidFormat { .. }));
362    }
363
364    #[test]
365    fn http_scheme_standalone_rejects_malformed() {
366        let result = HttpScheme::check(&"invalid".to_string());
367        assert!(result.is_err());
368        let err = result.unwrap_err();
369        assert_eq!(err.format_name, "HTTP URL");
370    }
371
372    // Composition tests
373    #[test]
374    fn and_combinator_validates_both_predicates() {
375        // Invalid URL should fail ValidUrl
376        assert!(HttpUrl::new("not a url".to_string()).is_err());
377
378        // Valid FTP should fail HttpScheme
379        assert!(HttpUrl::new("ftp://example.com".to_string()).is_err());
380
381        // Valid HTTPS should pass both
382        assert!(HttpUrl::new("https://example.com".to_string()).is_ok());
383    }
384
385    // Error message tests
386    #[test]
387    fn invalid_url_error_includes_format_name() {
388        let result = Url::new("invalid".to_string());
389        let err = result.unwrap_err();
390        assert_eq!(err.format_name, "URL");
391    }
392
393    #[test]
394    fn invalid_url_error_includes_example() {
395        let result = Url::new("invalid".to_string());
396        let err = result.unwrap_err();
397        assert_eq!(err.example, "https://example.com");
398    }
399
400    #[test]
401    fn scheme_error_is_invalid_component() {
402        let result = SecureUrl::new("http://example.com".to_string());
403        let err = result.unwrap_err();
404        // SecureUrl uses And combinator, extract the underlying DomainError
405        match err {
406            stillwater::refined::AndError::Second(domain_err) => match domain_err.reason {
407                DomainErrorKind::InvalidComponent { component, reason } => {
408                    assert_eq!(component, "scheme");
409                    assert!(reason.contains("https"));
410                }
411                _ => panic!("Expected InvalidComponent error"),
412            },
413            _ => panic!("Expected AndError::Second"),
414        }
415    }
416}