dynamo_runtime/transports/etcd/
path.rs1use once_cell::sync::Lazy;
7use std::str::FromStr;
8use validator::ValidationError;
9
10pub const ETCD_ROOT_PATH: &str = "v1/dynamo/";
12
13pub const COMPONENT_KEYWORD: &str = "_component_";
15
16pub const ENDPOINT_KEYWORD: &str = "_endpoint_";
18
19static ALLOWED_CHARS_REGEX: Lazy<regex::Regex> =
20 Lazy::new(|| regex::Regex::new(r"^[a-z0-9-_]+$").unwrap());
21
22#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct EtcdPath {
33 pub namespace: String,
35 pub component: Option<String>,
37 pub endpoint: Option<String>,
39 pub lease_id: Option<i64>,
41 pub extra_path: Option<Vec<String>>,
43}
44
45#[derive(Debug, thiserror::Error)]
47pub enum EtcdPathError {
48 #[error("Path must start with '{}'", ETCD_ROOT_PATH)]
49 InvalidPrefix,
50 #[error("Invalid namespace: {0}")]
51 InvalidNamespace(String),
52 #[error("Invalid component name: {0}")]
53 InvalidComponent(String),
54 #[error("Invalid endpoint name: {0}")]
55 InvalidEndpoint(String),
56 #[error("Invalid extra path segment: {0}")]
57 InvalidExtraPath(String),
58 #[error("Endpoint requires component to be present")]
59 EndpointWithoutComponent,
60 #[error("Expected '{}' keyword after namespace", COMPONENT_KEYWORD)]
61 ExpectedComponentKeyword,
62 #[error("Expected '{}' keyword after component", ENDPOINT_KEYWORD)]
63 ExpectedEndpointKeyword,
64 #[error("Reserved keyword '{0}' cannot be used in extra path")]
65 ReservedKeyword(String),
66 #[error("Empty namespace not allowed")]
67 EmptyNamespace,
68 #[error("Empty component name not allowed")]
69 EmptyComponent,
70 #[error("Empty endpoint name not allowed")]
71 EmptyEndpoint,
72}
73
74impl EtcdPath {
75 pub fn new_namespace(namespace: &str) -> Result<Self, EtcdPathError> {
77 validate_namespace(namespace)?;
78 Ok(Self {
79 namespace: namespace.to_string(),
80 component: None,
81 endpoint: None,
82 lease_id: None,
83 extra_path: None,
84 })
85 }
86
87 pub fn new_component(namespace: &str, component: &str) -> Result<Self, EtcdPathError> {
89 validate_namespace(namespace)?;
90 validate_component(component)?;
91 Ok(Self {
92 namespace: namespace.to_string(),
93 component: Some(component.to_string()),
94 endpoint: None,
95 lease_id: None,
96 extra_path: None,
97 })
98 }
99
100 pub fn new_endpoint(
102 namespace: &str,
103 component: &str,
104 endpoint: &str,
105 ) -> Result<Self, EtcdPathError> {
106 validate_namespace(namespace)?;
107 validate_component(component)?;
108 validate_endpoint(endpoint)?;
109 Ok(Self {
110 namespace: namespace.to_string(),
111 component: Some(component.to_string()),
112 endpoint: Some(endpoint.to_string()),
113 lease_id: None,
114 extra_path: None,
115 })
116 }
117
118 pub fn new_endpoint_with_lease(
120 namespace: &str,
121 component: &str,
122 endpoint: &str,
123 lease_id: i64,
124 ) -> Result<Self, EtcdPathError> {
125 validate_namespace(namespace)?;
126 validate_component(component)?;
127 validate_endpoint(endpoint)?;
128
129 Ok(Self {
130 namespace: namespace.to_string(),
131 component: Some(component.to_string()),
132 endpoint: Some(endpoint.to_string()),
133 lease_id: Some(lease_id),
134 extra_path: None,
135 })
136 }
137
138 pub fn with_extra_path(mut self, extra_path: Vec<String>) -> Result<Self, EtcdPathError> {
140 for segment in &extra_path {
141 validate_extra_path_segment(segment)?;
142 }
143 self.extra_path = if extra_path.is_empty() {
144 None
145 } else {
146 Some(extra_path)
147 };
148 self.lease_id = None;
149 Ok(self)
150 }
151
152 fn _to_string(&self) -> String {
154 let mut path = format!("{}{}", ETCD_ROOT_PATH, self.namespace);
155
156 if let Some(ref component) = self.component {
157 path.push('/');
158 path.push_str(COMPONENT_KEYWORD);
159 path.push('/');
160 path.push_str(component);
161
162 if let Some(ref endpoint) = self.endpoint {
163 path.push('/');
164 path.push_str(ENDPOINT_KEYWORD);
165 path.push('/');
166 path.push_str(endpoint);
167
168 if let Some(lease_id) = self.lease_id {
170 path.push(':');
171 path.push_str(&format!("{:x}", lease_id));
172 }
173 }
174 }
175
176 if let Some(ref extra_path) = self.extra_path {
177 for segment in extra_path {
178 path.push('/');
179 path.push_str(segment);
180 }
181 }
182
183 path
184 }
185
186 pub fn parse(input: &str) -> Result<Self, EtcdPathError> {
188 if !input.starts_with(ETCD_ROOT_PATH) {
190 return Err(EtcdPathError::InvalidPrefix);
191 }
192
193 let path_without_prefix = &input[ETCD_ROOT_PATH.len()..];
195 let segments: Vec<&str> = path_without_prefix.split('/').collect();
196
197 if segments.is_empty() || segments[0].is_empty() {
198 return Err(EtcdPathError::EmptyNamespace);
199 }
200
201 let namespace = segments[0].to_string();
203 validate_namespace(&namespace)?;
204
205 let mut etcd_path = Self {
206 namespace,
207 component: None,
208 endpoint: None,
209 lease_id: None,
210 extra_path: None,
211 };
212
213 let mut i = 1;
215 while i < segments.len() {
216 match segments[i] {
217 COMPONENT_KEYWORD => {
218 if i + 1 >= segments.len() {
219 return Err(EtcdPathError::EmptyComponent);
220 }
221 let component_name = segments[i + 1].to_string();
222 validate_component(&component_name)?;
223 etcd_path.component = Some(component_name);
224 i += 2;
225 }
226 ENDPOINT_KEYWORD => {
227 if etcd_path.component.is_none() {
228 return Err(EtcdPathError::EndpointWithoutComponent);
229 }
230 if i + 1 >= segments.len() {
231 return Err(EtcdPathError::EmptyEndpoint);
232 }
233 let endpoint_segment = segments[i + 1];
234
235 if let Some(colon_pos) = endpoint_segment.find(':') {
237 let endpoint_name = endpoint_segment[..colon_pos].to_string();
238 let lease_id_str = &endpoint_segment[colon_pos + 1..];
239
240 validate_endpoint(&endpoint_name)?;
241
242 let lease_id = i64::from_str_radix(lease_id_str, 16).map_err(|_| {
244 EtcdPathError::InvalidEndpoint(format!(
245 "Invalid lease ID format: {}",
246 lease_id_str
247 ))
248 })?;
249
250 etcd_path.endpoint = Some(endpoint_name);
251 etcd_path.lease_id = Some(lease_id);
252 } else {
253 let endpoint_name = endpoint_segment.to_string();
254 validate_endpoint(&endpoint_name)?;
255 etcd_path.endpoint = Some(endpoint_name);
256 }
257 i += 2;
258 }
259 _ => {
260 let mut extra_path = Vec::new();
262 while i < segments.len() {
263 validate_extra_path_segment(segments[i])?;
264 extra_path.push(segments[i].to_string());
265 i += 1;
266 }
267 etcd_path.extra_path = if extra_path.is_empty() {
268 None
269 } else {
270 Some(extra_path)
271 };
272 break;
273 }
274 }
275 }
276
277 Ok(etcd_path)
278 }
279}
280
281impl FromStr for EtcdPath {
282 type Err = EtcdPathError;
283
284 fn from_str(s: &str) -> Result<Self, Self::Err> {
285 Self::parse(s)
286 }
287}
288
289impl EtcdPath {
290 pub fn from_string(s: String) -> Result<Self, EtcdPathError> {
292 Self::parse(&s)
293 }
294}
295
296impl std::fmt::Display for EtcdPath {
297 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298 write!(f, "{}", self._to_string())
299 }
300}
301
302fn validate_namespace(namespace: &str) -> Result<(), EtcdPathError> {
304 if namespace.is_empty() {
305 return Err(EtcdPathError::EmptyNamespace);
306 }
307
308 for part in namespace.split('.') {
310 if part.is_empty() {
311 return Err(EtcdPathError::InvalidNamespace(format!(
312 "Empty namespace segment in '{}'",
313 namespace
314 )));
315 }
316 validate_allowed_chars(part).map_err(|_| {
317 EtcdPathError::InvalidNamespace(format!("Invalid characters in '{}'", part))
318 })?;
319 }
320 Ok(())
321}
322
323fn validate_component(component: &str) -> Result<(), EtcdPathError> {
325 if component.is_empty() {
326 return Err(EtcdPathError::EmptyComponent);
327 }
328 validate_allowed_chars(component)
329 .map_err(|_| EtcdPathError::InvalidComponent(component.to_string()))
330}
331
332fn validate_endpoint(endpoint: &str) -> Result<(), EtcdPathError> {
334 if endpoint.is_empty() {
335 return Err(EtcdPathError::EmptyEndpoint);
336 }
337 validate_allowed_chars(endpoint)
338 .map_err(|_| EtcdPathError::InvalidEndpoint(endpoint.to_string()))
339}
340
341fn validate_extra_path_segment(segment: &str) -> Result<(), EtcdPathError> {
343 if segment.is_empty() {
344 return Err(EtcdPathError::InvalidExtraPath(
345 "Empty path segment".to_string(),
346 ));
347 }
348
349 if segment == COMPONENT_KEYWORD {
351 return Err(EtcdPathError::ReservedKeyword(segment.to_string()));
352 }
353 if segment == ENDPOINT_KEYWORD {
354 return Err(EtcdPathError::ReservedKeyword(segment.to_string()));
355 }
356
357 validate_allowed_chars(segment)
358 .map_err(|_| EtcdPathError::InvalidExtraPath(segment.to_string()))
359}
360
361fn validate_allowed_chars(input: &str) -> Result<(), ValidationError> {
363 if ALLOWED_CHARS_REGEX.is_match(input) {
364 Ok(())
365 } else {
366 Err(ValidationError::new("invalid_characters"))
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn test_namespace_and_component() {
376 let s = format!("{ETCD_ROOT_PATH}ns1.ns2/_component_/my-component");
377 let path = EtcdPath::parse(&s).unwrap();
378 assert_eq!(path.namespace, "ns1.ns2");
379 assert_eq!(path.component, Some("my-component".to_string()));
380 assert_eq!(path.endpoint, None);
381 assert_eq!(path.extra_path, None);
382 assert_eq!(path.to_string(), s);
383 }
384
385 #[test]
386 fn test_full_path_with_endpoint() {
387 let s = format!(
388 "{ETCD_ROOT_PATH}ns1.ns2.ns3/_component_/component-name/_endpoint_/endpoint-name"
389 );
390 let path = EtcdPath::parse(&s).unwrap();
391 assert_eq!(path.namespace, "ns1.ns2.ns3");
392 assert_eq!(path.component, Some("component-name".to_string()));
393 assert_eq!(path.endpoint, Some("endpoint-name".to_string()));
394 assert_eq!(path.extra_path, None);
395 assert_eq!(path.to_string(), s);
396 }
397
398 #[test]
399 fn test_invalid_prefix() {
400 let result = EtcdPath::parse("invalid://ns1");
401 assert!(matches!(result, Err(EtcdPathError::InvalidPrefix)));
402 }
403
404 #[test]
405 fn test_invalid_characters() {
406 let result = EtcdPath::parse(&format!("{ETCD_ROOT_PATH}ns1!/_component_/comp1"));
407 assert!(matches!(result, Err(EtcdPathError::InvalidNamespace(_))));
408 }
409
410 #[test]
411 fn test_constructor_methods() {
412 let path = EtcdPath::new_namespace("ns1.ns2.ns3").unwrap();
413 assert_eq!(path.to_string(), format!("{ETCD_ROOT_PATH}ns1.ns2.ns3"));
414
415 let path = EtcdPath::new_component("ns1.ns2", "comp1").unwrap();
416 assert_eq!(
417 path.to_string(),
418 format!("{ETCD_ROOT_PATH}ns1.ns2/_component_/comp1")
419 );
420
421 let path = EtcdPath::new_endpoint("ns1", "comp1", "ep1").unwrap();
422 assert_eq!(
423 path.to_string(),
424 format!("{ETCD_ROOT_PATH}ns1/_component_/comp1/_endpoint_/ep1")
425 );
426 }
427
428 #[test]
429 fn test_with_extra_path_method() {
430 let path = EtcdPath::new_component("ns1", "comp1")
431 .unwrap()
432 .with_extra_path(vec!["path1".to_string(), "path2".to_string()])
433 .unwrap();
434 assert_eq!(
435 path.to_string(),
436 format!("{ETCD_ROOT_PATH}ns1/_component_/comp1/path1/path2")
437 );
438 }
439
440 #[test]
441 fn test_endpoint_with_lease_id() {
442 let path = EtcdPath::new_endpoint_with_lease("ns1", "comp1", "ep1", 0xabc123).unwrap();
444 assert_eq!(path.namespace, "ns1");
445 assert_eq!(path.component, Some("comp1".to_string()));
446 assert_eq!(path.endpoint, Some("ep1".to_string()));
447 assert_eq!(path.lease_id, Some(0xabc123));
448 assert_eq!(
449 path.to_string(),
450 format!("{ETCD_ROOT_PATH}ns1/_component_/comp1/_endpoint_/ep1:abc123")
451 );
452 }
453
454 #[test]
455 fn test_parse_endpoint_with_lease_id() {
456 let path = EtcdPath::parse(&format!(
458 "{ETCD_ROOT_PATH}ns1/_component_/comp1/_endpoint_/ep1:abc123"
459 ))
460 .unwrap();
461 assert_eq!(path.namespace, "ns1");
462 assert_eq!(path.component, Some("comp1".to_string()));
463 assert_eq!(path.endpoint, Some("ep1".to_string()));
464 assert_eq!(path.lease_id, Some(0xabc123));
465 assert_eq!(path.extra_path, None);
466 }
467
468 #[test]
469 fn test_parse_endpoint_without_lease_id() {
470 let path = EtcdPath::parse(&format!(
472 "{ETCD_ROOT_PATH}ns1/_component_/comp1/_endpoint_/ep1"
473 ))
474 .unwrap();
475 assert_eq!(path.namespace, "ns1");
476 assert_eq!(path.component, Some("comp1".to_string()));
477 assert_eq!(path.endpoint, Some("ep1".to_string()));
478 assert_eq!(path.lease_id, None);
479 assert_eq!(path.extra_path, None);
480 }
481
482 #[test]
483 fn test_invalid_lease_id_format() {
484 let result = EtcdPath::parse(&format!(
486 "{ETCD_ROOT_PATH}ns1/_component_/comp1/_endpoint_/ep1:invalid"
487 ));
488 assert!(matches!(result, Err(EtcdPathError::InvalidEndpoint(_))));
489 }
490
491 #[test]
492 fn test_lease_id_round_trip() {
493 let original_path =
495 EtcdPath::new_endpoint_with_lease("production", "api-gateway", "http", 0xdeadbeef)
496 .unwrap();
497
498 let path_string = original_path.to_string();
500 assert_eq!(
501 path_string,
502 format!("{ETCD_ROOT_PATH}production/_component_/api-gateway/_endpoint_/http:deadbeef")
503 );
504
505 let parsed_path = EtcdPath::parse(&path_string).unwrap();
507
508 assert_eq!(parsed_path.namespace, "production");
510 assert_eq!(parsed_path.component, Some("api-gateway".to_string()));
511 assert_eq!(parsed_path.endpoint, Some("http".to_string()));
512 assert_eq!(parsed_path.lease_id, Some(0xdeadbeef));
513 assert_eq!(parsed_path.extra_path, None);
514
515 assert_eq!(parsed_path, original_path);
517 }
518
519 #[test]
520 fn test_lease_id_edge_cases() {
521 let path = EtcdPath::new_endpoint_with_lease("ns", "comp", "ep", 0).unwrap();
523 assert_eq!(
524 path.to_string(),
525 format!("{ETCD_ROOT_PATH}ns/_component_/comp/_endpoint_/ep:0")
526 );
527
528 let path = EtcdPath::new_endpoint_with_lease("ns", "comp", "ep", i64::MAX).unwrap();
530 assert_eq!(
531 path.to_string(),
532 format!("{ETCD_ROOT_PATH}ns/_component_/comp/_endpoint_/ep:7fffffffffffffff")
533 );
534
535 let parsed = EtcdPath::parse(&format!(
537 "{ETCD_ROOT_PATH}ns/_component_/comp/_endpoint_/ep:7fffffffffffffff"
538 ))
539 .unwrap();
540 assert_eq!(parsed.lease_id, Some(i64::MAX));
541 }
542}