1use crate::error::{ErrorData, Result};
2use alien_error::AlienError;
3use std::collections::HashMap;
4use url::Url;
5
6pub type PublicEndpointUrls = HashMap<String, HashMap<String, String>>;
8
9pub fn primary_public_endpoint_url<'a>(
17 urls: &'a HashMap<String, String>,
18 first_declared_endpoint: Option<&str>,
19) -> Option<&'a String> {
20 first_declared_endpoint
21 .and_then(|name| urls.get(name))
22 .or_else(|| {
23 urls.iter()
24 .min_by(|(left, _), (right, _)| left.cmp(right))
25 .map(|(_, url)| url)
26 })
27}
28
29pub fn parse_public_endpoint_assignment(value: &str) -> Result<(String, String, String)> {
31 let (key, public_url) = value.split_once('=').ok_or_else(|| {
32 AlienError::new(ErrorData::PublicUrlInvalid {
33 resource_id: "<missing>".to_string(),
34 reason: "expected <resource-id>.<endpoint-name>=<absolute-url>".to_string(),
35 })
36 })?;
37 let key = key.trim();
38 let public_url = public_url.trim();
39 let (resource_id, endpoint_name) = key.split_once('.').ok_or_else(|| {
40 AlienError::new(ErrorData::PublicUrlInvalid {
41 resource_id: key.to_string(),
42 reason: "expected <resource-id>.<endpoint-name> before '='".to_string(),
43 })
44 })?;
45 validate_public_endpoint_url(resource_id, endpoint_name, public_url)?;
46 Ok((
47 resource_id.to_string(),
48 endpoint_name.to_string(),
49 public_url.to_string(),
50 ))
51}
52
53pub fn validate_public_endpoint_urls(public_endpoints: &PublicEndpointUrls) -> Result<()> {
55 for (resource_id, endpoints) in public_endpoints {
56 if endpoints.is_empty() {
57 return Err(AlienError::new(ErrorData::PublicUrlInvalid {
58 resource_id: resource_id.to_string(),
59 reason: "at least one endpoint URL is required when a resource is present"
60 .to_string(),
61 }));
62 }
63 for (endpoint_name, public_url) in endpoints {
64 validate_public_endpoint_url(resource_id, endpoint_name, public_url)?;
65 }
66 }
67 Ok(())
68}
69
70pub fn validate_public_endpoint_url(
72 resource_id: &str,
73 endpoint_name: &str,
74 public_url: &str,
75) -> Result<()> {
76 validate_key_part("resource ID", resource_id, resource_id)?;
77 validate_key_part("endpoint name", resource_id, endpoint_name)?;
78 if public_url.trim().is_empty() {
79 return Err(AlienError::new(ErrorData::PublicUrlInvalid {
80 resource_id: resource_id.to_string(),
81 reason: format!("URL is required for endpoint '{endpoint_name}'"),
82 }));
83 }
84
85 let parsed = Url::parse(public_url).map_err(|err| {
86 AlienError::new(ErrorData::PublicUrlInvalid {
87 resource_id: resource_id.to_string(),
88 reason: format!("endpoint '{endpoint_name}' URL must be absolute: {err}"),
89 })
90 })?;
91
92 match parsed.scheme() {
93 "http" | "https" => {}
94 scheme => {
95 return Err(AlienError::new(ErrorData::PublicUrlInvalid {
96 resource_id: resource_id.to_string(),
97 reason: format!(
98 "endpoint '{endpoint_name}' URL scheme must be http or https, got '{scheme}'"
99 ),
100 }));
101 }
102 }
103
104 let Some(host) = parsed.host_str() else {
105 return Err(AlienError::new(ErrorData::PublicUrlInvalid {
106 resource_id: resource_id.to_string(),
107 reason: format!("endpoint '{endpoint_name}' URL must include a host"),
108 }));
109 };
110 if host.contains('*') {
111 return Err(AlienError::new(ErrorData::PublicUrlInvalid {
112 resource_id: resource_id.to_string(),
113 reason: format!(
114 "endpoint '{endpoint_name}' URL host must be the base hostname, not a wildcard"
115 ),
116 }));
117 }
118 if parsed.query().is_some() || parsed.fragment().is_some() {
119 return Err(AlienError::new(ErrorData::PublicUrlInvalid {
120 resource_id: resource_id.to_string(),
121 reason: format!(
122 "endpoint '{endpoint_name}' URL must not include query parameters or a fragment"
123 ),
124 }));
125 }
126 if parsed.path() != "/" {
127 return Err(AlienError::new(ErrorData::PublicUrlInvalid {
128 resource_id: resource_id.to_string(),
129 reason: format!("endpoint '{endpoint_name}' URL path must be empty or '/'"),
130 }));
131 }
132
133 Ok(())
134}
135
136fn validate_key_part(label: &str, resource_id: &str, value: &str) -> Result<()> {
137 if value.trim().is_empty() {
138 return Err(AlienError::new(ErrorData::PublicUrlInvalid {
139 resource_id: resource_id.to_string(),
140 reason: format!("{label} is required"),
141 }));
142 }
143 if value.trim() != value {
144 return Err(AlienError::new(ErrorData::PublicUrlInvalid {
145 resource_id: resource_id.to_string(),
146 reason: format!("{label} must not contain leading or trailing whitespace"),
147 }));
148 }
149 Ok(())
150}
151
152pub fn public_url_host(public_url: &str) -> Option<String> {
154 Url::parse(public_url)
155 .ok()
156 .and_then(|url| {
157 url.host_str()
158 .map(|host| host.trim_end_matches('.').to_string())
159 })
160 .filter(|host| !host.is_empty())
161}
162
163pub fn public_url_port(public_url: &str) -> Option<u16> {
165 Url::parse(public_url)
166 .ok()
167 .and_then(|url| url.port_or_known_default())
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn parses_public_endpoint_assignment() {
176 let (resource_id, endpoint_name, public_url) =
177 parse_public_endpoint_assignment("gateway.api=https://api.example.test")
178 .expect("assignment should parse");
179
180 assert_eq!(resource_id, "gateway");
181 assert_eq!(endpoint_name, "api");
182 assert_eq!(public_url, "https://api.example.test");
183 }
184
185 #[test]
186 fn rejects_invalid_public_endpoint_urls() {
187 for value in [
188 "gateway",
189 "gateway=https://gateway.example.test",
190 ".api=https://gateway.example.test",
191 "gateway.=https://gateway.example.test",
192 "gateway.api=",
193 "gateway.api=ftp://gateway.example.test",
194 "gateway.api=https://*.gateway.example.test",
195 "gateway.api=https://gateway.example.test/path",
196 "gateway.api=https://gateway.example.test?x=1",
197 "gateway.api=https://gateway.example.test#frag",
198 ] {
199 assert!(
200 parse_public_endpoint_assignment(value).is_err(),
201 "{value} should be invalid"
202 );
203 }
204 }
205
206 #[test]
207 fn extracts_public_url_host() {
208 assert_eq!(
209 public_url_host("https://gateway.example.test:8443"),
210 Some("gateway.example.test".to_string())
211 );
212 assert_eq!(public_url_host("not a url"), None);
213 }
214
215 #[test]
216 fn extracts_effective_public_url_port() {
217 assert_eq!(public_url_port("https://gateway.example.test"), Some(443));
218 assert_eq!(public_url_port("http://localhost:8080"), Some(8080));
219 assert_eq!(public_url_port("not a url"), None);
220 }
221
222 #[test]
223 fn primary_public_endpoint_url_follows_declaration_order() {
224 let names = ["web", "admin", "api"];
226 for round in 0..64 {
227 let mut urls = HashMap::new();
229 for index in 0..names.len() {
230 let name = names[(index + round) % names.len()];
231 urls.insert(name.to_string(), format!("https://{name}.example.com"));
232 }
233 assert_eq!(
234 primary_public_endpoint_url(&urls, Some("web")).map(String::as_str),
235 Some("https://web.example.com")
236 );
237 urls.remove("web");
238 assert_eq!(
239 primary_public_endpoint_url(&urls, Some("web")).map(String::as_str),
240 Some("https://admin.example.com"),
241 "without the first declared endpoint the smallest name wins"
242 );
243 }
244 }
245}