Skip to main content

lychee_lib/ratelimit/host/
key.rs

1use serde::Deserialize;
2use std::fmt;
3use url::Url;
4
5use crate::ErrorKind;
6use crate::types::Result;
7
8/// A type-safe representation of a hostname for rate limiting purposes.
9///
10/// This extracts and normalizes hostnames from URLs to ensure consistent
11/// rate limiting across requests to the same host (domain or IP address).
12///
13/// # Examples
14///
15/// ```
16/// use lychee_lib::ratelimit::HostKey;
17/// use url::Url;
18///
19/// let url = Url::parse("https://api.github.com/repos/user/repo").unwrap();
20/// let host_key = HostKey::try_from(&url).unwrap();
21/// assert_eq!(host_key.as_str(), "api.github.com");
22/// ```
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
24pub struct HostKey(String);
25
26impl HostKey {
27    /// Get the hostname as a string slice
28    #[must_use]
29    pub fn as_str(&self) -> &str {
30        &self.0
31    }
32
33    /// Get the hostname as an owned String
34    #[must_use]
35    pub fn into_string(self) -> String {
36        self.0
37    }
38}
39
40impl TryFrom<&Url> for HostKey {
41    type Error = ErrorKind;
42
43    fn try_from(url: &Url) -> Result<Self> {
44        let host = url.host_str().ok_or_else(|| ErrorKind::InvalidUrlHost)?;
45
46        // Normalize to lowercase for consistent lookup
47        Ok(HostKey(host.to_lowercase()))
48    }
49}
50
51impl TryFrom<&crate::Uri> for HostKey {
52    type Error = ErrorKind;
53
54    fn try_from(uri: &crate::Uri) -> Result<Self> {
55        Self::try_from(&uri.url)
56    }
57}
58
59impl TryFrom<Url> for HostKey {
60    type Error = ErrorKind;
61
62    fn try_from(url: Url) -> Result<Self> {
63        HostKey::try_from(&url)
64    }
65}
66
67impl fmt::Display for HostKey {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        write!(f, "{}", self.0)
70    }
71}
72
73impl From<String> for HostKey {
74    fn from(host: String) -> Self {
75        HostKey(host.to_lowercase())
76    }
77}
78
79impl From<&str> for HostKey {
80    fn from(host: &str) -> Self {
81        HostKey(host.to_lowercase())
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_host_key_from_url() {
91        let url = Url::parse("https://api.github.com/repos/user/repo").unwrap();
92        let host_key = HostKey::try_from(&url).unwrap();
93        assert_eq!(host_key.as_str(), "api.github.com");
94    }
95
96    #[test]
97    fn test_host_key_normalization() {
98        let url = Url::parse("https://API.GITHUB.COM/repos/user/repo").unwrap();
99        let host_key = HostKey::try_from(&url).unwrap();
100        assert_eq!(host_key.as_str(), "api.github.com");
101    }
102
103    #[test]
104    fn test_host_key_subdomain_separation() {
105        let api_url = Url::parse("https://api.github.com/").unwrap();
106        let www_url = Url::parse("https://www.github.com/").unwrap();
107
108        let api_key = HostKey::try_from(&api_url).unwrap();
109        let www_key = HostKey::try_from(&www_url).unwrap();
110
111        assert_ne!(api_key, www_key);
112        assert_eq!(api_key.as_str(), "api.github.com");
113        assert_eq!(www_key.as_str(), "www.github.com");
114    }
115
116    #[test]
117    fn test_host_key_from_string() {
118        let host_key = HostKey::from("example.com");
119        assert_eq!(host_key.as_str(), "example.com");
120
121        let host_key = HostKey::from("EXAMPLE.COM");
122        assert_eq!(host_key.as_str(), "example.com");
123    }
124
125    #[test]
126    fn test_host_key_no_host() {
127        let url = Url::parse("file:///path/to/file").unwrap();
128        let result = HostKey::try_from(&url);
129        assert!(result.is_err());
130    }
131
132    #[test]
133    fn test_host_key_display() {
134        let host_key = HostKey::from("example.com");
135        assert_eq!(format!("{host_key}"), "example.com");
136    }
137
138    #[test]
139    fn test_host_key_hash_equality() {
140        use std::collections::HashMap;
141
142        let key1 = HostKey::from("example.com");
143        let key2 = HostKey::from("EXAMPLE.COM");
144
145        let mut map = HashMap::new();
146        map.insert(key1, "value");
147
148        // Should find the value with normalized key
149        assert_eq!(map.get(&key2), Some(&"value"));
150    }
151}