uv_configuration/
proxy_url.rs1#[cfg(feature = "schemars")]
2use std::borrow::Cow;
3use std::fmt::{self, Display, Formatter};
4use std::str::FromStr;
5
6use reqwest::Proxy;
7use serde::{Deserialize, Deserializer, Serialize};
8use url::Url;
9
10use uv_redacted::DisplaySafeUrl;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct ProxyUrl(DisplaySafeUrl);
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum ProxyUrlKind {
21 Http,
22 Https,
23}
24
25impl ProxyUrl {
26 fn as_url(&self) -> &DisplaySafeUrl {
28 &self.0
29 }
30
31 pub fn as_proxy(&self, kind: ProxyUrlKind) -> Proxy {
33 match kind {
35 ProxyUrlKind::Http => Proxy::http(self.0.as_str())
36 .expect("Constructing a proxy from a url should never fail"),
37 ProxyUrlKind::Https => Proxy::https(self.0.as_str())
38 .expect("Constructing a proxy from a url should never fail"),
39 }
40 }
41}
42
43#[derive(Debug, thiserror::Error)]
44pub enum ProxyUrlError {
45 #[error("invalid proxy URL: {0}")]
46 InvalidUrl(#[from] url::ParseError),
47 #[error(
48 "invalid proxy URL scheme `{scheme}` in `{url}`: expected http, https, socks5, or socks5h"
49 )]
50 InvalidScheme { scheme: String, url: DisplaySafeUrl },
51}
52
53fn lacks_scheme(s: &str) -> bool {
55 !s.contains("://")
56}
57
58impl FromStr for ProxyUrl {
59 type Err = ProxyUrlError;
60
61 fn from_str(s: &str) -> Result<Self, Self::Err> {
65 fn try_with_http_scheme(s: &str) -> Result<ProxyUrl, ProxyUrlError> {
66 let with_scheme = format!("http://{s}");
67 let url = Url::parse(&with_scheme)?;
68 ProxyUrl::try_from(url)
69 }
70
71 match Url::parse(s) {
72 Ok(url) => match Self::try_from(url) {
73 Ok(proxy) => Ok(proxy),
74 Err(ProxyUrlError::InvalidScheme { .. }) if lacks_scheme(s) => {
75 try_with_http_scheme(s)
76 }
77 Err(e) => Err(e),
78 },
79 Err(url::ParseError::RelativeUrlWithoutBase) => try_with_http_scheme(s),
80 Err(err) => Err(ProxyUrlError::InvalidUrl(err)),
81 }
82 }
83}
84
85impl TryFrom<Url> for ProxyUrl {
86 type Error = ProxyUrlError;
87
88 fn try_from(url: Url) -> Result<Self, Self::Error> {
89 let url = DisplaySafeUrl::from_url(url);
90 match url.scheme() {
91 "http" | "https" | "socks5" | "socks5h" => Ok(Self(url)),
92 scheme => Err(ProxyUrlError::InvalidScheme {
93 scheme: scheme.to_string(),
94 url,
95 }),
96 }
97 }
98}
99
100impl Display for ProxyUrl {
101 fn fmt(&self, f: &mut Formatter) -> fmt::Result {
102 Display::fmt(&self.0, f)
103 }
104}
105
106impl<'de> Deserialize<'de> for ProxyUrl {
107 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108 where
109 D: Deserializer<'de>,
110 {
111 let s = String::deserialize(deserializer)?;
112 Self::from_str(&s).map_err(serde::de::Error::custom)
113 }
114}
115
116impl Serialize for ProxyUrl {
117 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118 where
119 S: serde::ser::Serializer,
120 {
121 serializer.serialize_str(self.as_url().as_str())
122 }
123}
124
125#[cfg(feature = "schemars")]
126impl schemars::JsonSchema for ProxyUrl {
127 fn schema_name() -> Cow<'static, str> {
128 Cow::Borrowed("ProxyUrl")
129 }
130
131 fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
132 schemars::json_schema!({
133 "type": "string",
134 "format": "uri",
135 "description": "A proxy URL (e.g., `http://proxy.example.com:8080`)."
136 })
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn parse_valid_proxy_urls() {
146 let url = "http://proxy.example.com:8080".parse::<ProxyUrl>().unwrap();
148 assert_eq!(url.to_string(), "http://proxy.example.com:8080/");
149
150 let url = "https://proxy.example.com:8080"
152 .parse::<ProxyUrl>()
153 .unwrap();
154 assert_eq!(url.to_string(), "https://proxy.example.com:8080/");
155
156 let url = "socks5://proxy.example.com:1080"
158 .parse::<ProxyUrl>()
159 .unwrap();
160 assert_eq!(url.to_string(), "socks5://proxy.example.com:1080");
161
162 let url = "socks5h://proxy.example.com:1080"
164 .parse::<ProxyUrl>()
165 .unwrap();
166 assert_eq!(url.to_string(), "socks5h://proxy.example.com:1080");
167
168 let url = "http://user:pass@proxy.example.com:8080"
170 .parse::<ProxyUrl>()
171 .unwrap();
172 assert_eq!(
173 Url::from(url.as_url().clone()).to_string(),
174 "http://user:pass@proxy.example.com:8080/"
175 );
176 }
177
178 #[test]
179 fn parse_proxy_url_without_scheme() {
180 let url = "proxy.example.com:8080".parse::<ProxyUrl>().unwrap();
183 assert_eq!(url.to_string(), "http://proxy.example.com:8080/");
184
185 let url = "user:pass@proxy.example.com:8080"
187 .parse::<ProxyUrl>()
188 .unwrap();
189 assert_eq!(
190 Url::from(url.as_url().clone()).to_string(),
191 "http://user:pass@proxy.example.com:8080/"
192 );
193
194 let url = "proxy.example.com".parse::<ProxyUrl>().unwrap();
196 assert_eq!(url.to_string(), "http://proxy.example.com/");
197 }
198
199 #[test]
200 fn parse_invalid_proxy_urls() {
201 let result = "ftp://proxy.example.com:8080".parse::<ProxyUrl>();
202 assert!(matches!(result, Err(ProxyUrlError::InvalidScheme { .. })));
203 insta::assert_snapshot!(
204 result.unwrap_err().to_string(),
205 @"invalid proxy URL scheme `ftp` in `ftp://proxy.example.com:8080/`: expected http, https, socks5, or socks5h"
206 );
207
208 let result = "not a url".parse::<ProxyUrl>();
210 assert!(matches!(result, Err(ProxyUrlError::InvalidUrl(_))));
211 insta::assert_snapshot!(
212 result.unwrap_err().to_string(),
213 @"invalid proxy URL: invalid international domain name"
214 );
215
216 let result = "".parse::<ProxyUrl>();
218 assert!(matches!(result, Err(ProxyUrlError::InvalidUrl(_))));
219 insta::assert_snapshot!(
220 result.unwrap_err().to_string(),
221 @"invalid proxy URL: empty host"
222 );
223
224 let result = "file:///path/to/file".parse::<ProxyUrl>();
225 assert!(matches!(result, Err(ProxyUrlError::InvalidScheme { .. })));
226 insta::assert_snapshot!(
227 result.unwrap_err().to_string(),
228 @"invalid proxy URL scheme `file` in `file:///path/to/file`: expected http, https, socks5, or socks5h"
229 );
230 }
231
232 #[test]
233 fn deserialize_invalid_proxy_url() {
234 let result: Result<ProxyUrl, _> = serde_json::from_str(r#""ftp://proxy.example.com:8080""#);
235 insta::assert_snapshot!(
236 result.unwrap_err().to_string(),
237 @"invalid proxy URL scheme `ftp` in `ftp://proxy.example.com:8080/`: expected http, https, socks5, or socks5h"
238 );
239
240 let result: Result<ProxyUrl, _> = serde_json::from_str(r#""not a url""#);
241 insta::assert_snapshot!(
242 result.unwrap_err().to_string(),
243 @"invalid proxy URL: invalid international domain name"
244 );
245 }
246}