1use axum::Json;
8use axum::http::StatusCode;
9use axum::response::{IntoResponse, Response};
10use uuid::Uuid;
11
12use crate::config::LinksConfig;
13use crate::core::LinkDefinition;
14use crate::links::registry::{LinkDirection, LinkRouteRegistry};
15
16#[derive(Debug, Clone)]
18pub enum ExtractorError {
19 InvalidPath,
20 InvalidEntityId,
21 RouteNotFound(String),
22 LinkNotFound,
23 JsonError(String),
24}
25
26impl std::fmt::Display for ExtractorError {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 ExtractorError::InvalidPath => write!(f, "Invalid path format"),
30 ExtractorError::InvalidEntityId => write!(f, "Invalid entity ID format"),
31 ExtractorError::RouteNotFound(route) => write!(f, "Route not found: {}", route),
32 ExtractorError::LinkNotFound => write!(f, "Link not found"),
33 ExtractorError::JsonError(msg) => write!(f, "JSON error: {}", msg),
34 }
35 }
36}
37
38impl std::error::Error for ExtractorError {}
39
40impl IntoResponse for ExtractorError {
41 fn into_response(self) -> Response {
42 let (status, message) = match self {
43 ExtractorError::InvalidPath => (StatusCode::BAD_REQUEST, self.to_string()),
44 ExtractorError::InvalidEntityId => (StatusCode::BAD_REQUEST, self.to_string()),
45 ExtractorError::RouteNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
46 ExtractorError::LinkNotFound => (StatusCode::NOT_FOUND, self.to_string()),
47 ExtractorError::JsonError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
48 };
49
50 (status, Json(serde_json::json!({ "error": message }))).into_response()
51 }
52}
53
54#[derive(Debug, Clone)]
59pub struct LinkExtractor {
60 pub entity_id: Uuid,
61 pub entity_type: String,
62 pub link_definition: LinkDefinition,
63 pub direction: LinkDirection,
64}
65
66impl LinkExtractor {
67 pub fn from_path_and_registry(
72 path_parts: (String, Uuid, String),
73 registry: &LinkRouteRegistry,
74 config: &LinksConfig,
75 ) -> Result<Self, ExtractorError> {
76 let (entity_type_plural, entity_id, route_name) = path_parts;
77
78 let entity_type = config
80 .entities
81 .iter()
82 .find(|e| e.plural == entity_type_plural)
83 .map(|e| e.singular.clone())
84 .unwrap_or(entity_type_plural);
85
86 let (link_definition, direction) = registry
88 .resolve_route(&entity_type, &route_name)
89 .map_err(|_| ExtractorError::RouteNotFound(route_name.clone()))?;
90
91 Ok(Self {
92 entity_id,
93 entity_type,
94 link_definition,
95 direction,
96 })
97 }
98}
99
100#[derive(Debug, Clone)]
108pub struct DirectLinkExtractor {
109 pub source_id: Uuid,
110 pub source_type: String,
111 pub target_id: Uuid,
112 pub target_type: String,
113 pub link_definition: LinkDefinition,
114 pub direction: LinkDirection,
115}
116
117impl DirectLinkExtractor {
118 pub fn from_path(
125 path_parts: (String, Uuid, String, Uuid),
126 registry: &LinkRouteRegistry,
127 config: &LinksConfig,
128 ) -> Result<Self, ExtractorError> {
129 let (source_type_plural, source_id, route_name, target_id) = path_parts;
130
131 let source_type = config
133 .entities
134 .iter()
135 .find(|e| e.plural == source_type_plural)
136 .map(|e| e.singular.clone())
137 .unwrap_or(source_type_plural);
138
139 let (link_definition, direction) = registry
141 .resolve_route(&source_type, &route_name)
142 .map_err(|_| ExtractorError::RouteNotFound(route_name.clone()))?;
143
144 let target_type = match direction {
146 LinkDirection::Forward => link_definition.target_type.clone(),
147 LinkDirection::Reverse => link_definition.source_type.clone(),
148 };
149
150 Ok(Self {
151 source_id,
152 source_type,
153 target_id,
154 target_type,
155 link_definition,
156 direction,
157 })
158 }
159}
160
161#[derive(Debug, Clone, serde::Serialize)]
163pub struct LinkPathSegment {
164 pub entity_type: String,
166 pub entity_id: Uuid,
168 pub route_name: Option<String>,
170 pub link_definition: Option<LinkDefinition>,
172 #[serde(skip_serializing)]
174 pub link_direction: Option<LinkDirection>,
175}
176
177#[derive(Debug, Clone)]
183pub struct RecursiveLinkExtractor {
184 pub chain: Vec<LinkPathSegment>,
185 pub is_list: bool,
188}
189
190impl RecursiveLinkExtractor {
191 pub fn from_segments(
193 segments: Vec<String>,
194 registry: &LinkRouteRegistry,
195 config: &LinksConfig,
196 ) -> Result<Self, ExtractorError> {
197 if segments.len() < 2 {
198 return Err(ExtractorError::InvalidPath);
199 }
200
201 let mut chain = Vec::new();
202 let mut i = 0;
203 let mut current_entity_type: Option<String> = None;
204
205 while i < segments.len() {
208 let entity_type_singular = if let Some(ref entity_type) = current_entity_type {
210 entity_type.clone()
212 } else {
213 let entity_type_plural = &segments[i];
215 let singular = config
216 .entities
217 .iter()
218 .find(|e| e.plural == *entity_type_plural)
219 .map(|e| e.singular.clone())
220 .ok_or(ExtractorError::InvalidPath)?;
221 i += 1;
222 singular
223 };
224
225 current_entity_type = None;
227
228 let entity_id = if i < segments.len() {
230 segments[i]
231 .parse::<Uuid>()
232 .map_err(|_| ExtractorError::InvalidEntityId)?
233 } else {
234 chain.push(LinkPathSegment {
236 entity_type: entity_type_singular,
237 entity_id: Uuid::nil(),
238 route_name: None,
239 link_definition: None,
240 link_direction: None,
241 });
242 break;
243 };
244 i += 1;
245
246 let route_name = if i < segments.len() {
248 Some(segments[i].clone())
249 } else {
250 None
251 };
252
253 if route_name.is_some() {
254 i += 1;
255 }
256
257 let (link_def, link_dir) = if let Some(route_name) = &route_name {
259 let (link_def, direction) = registry
260 .resolve_route(&entity_type_singular, route_name)
261 .map_err(|_| ExtractorError::RouteNotFound(route_name.clone()))?;
262
263 current_entity_type = Some(match direction {
267 crate::links::registry::LinkDirection::Forward => link_def.target_type.clone(),
268 crate::links::registry::LinkDirection::Reverse => link_def.source_type.clone(),
269 });
270
271 (Some(link_def), Some(direction))
272 } else {
273 (None, None)
274 };
275
276 chain.push(LinkPathSegment {
277 entity_type: entity_type_singular,
278 entity_id,
279 route_name,
280 link_definition: link_def,
281 link_direction: link_dir,
282 });
283 }
284
285 if let Some(final_entity_type) = current_entity_type {
288 chain.push(LinkPathSegment {
289 entity_type: final_entity_type,
290 entity_id: Uuid::nil(), route_name: None,
292 link_definition: None,
293 link_direction: None,
294 });
295 }
296
297 let is_list = (segments.len() % 2 == 1) && (segments.len() >= 5);
302
303 Ok(Self { chain, is_list })
304 }
305
306 pub fn final_target(&self) -> (Uuid, String) {
308 let last = self.chain.last().unwrap();
309 (last.entity_id, last.entity_type.clone())
310 }
311
312 pub fn final_link_def(&self) -> Option<&LinkDefinition> {
314 if self.chain.len() >= 2 {
316 self.chain
317 .get(self.chain.len() - 2)
318 .and_then(|s| s.link_definition.as_ref())
319 } else {
320 None
321 }
322 }
323
324 pub fn penultimate_segment(&self) -> Option<&LinkPathSegment> {
326 if self.chain.len() >= 2 {
327 self.chain.get(self.chain.len() - 2)
328 } else {
329 None
330 }
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::config::{EntityAuthConfig, EntityConfig, LinksConfig};
338 use crate::core::LinkDefinition;
339 use crate::links::registry::LinkRouteRegistry;
340 use std::sync::Arc;
341 use uuid::Uuid;
342
343 fn test_config_and_registry() -> (Arc<LinksConfig>, LinkRouteRegistry) {
347 let config = Arc::new(LinksConfig {
348 entities: vec![
349 EntityConfig {
350 singular: "user".to_string(),
351 plural: "users".to_string(),
352 auth: EntityAuthConfig::default(),
353 },
354 EntityConfig {
355 singular: "order".to_string(),
356 plural: "orders".to_string(),
357 auth: EntityAuthConfig::default(),
358 },
359 EntityConfig {
360 singular: "invoice".to_string(),
361 plural: "invoices".to_string(),
362 auth: EntityAuthConfig::default(),
363 },
364 ],
365 links: vec![
366 LinkDefinition {
367 link_type: "ownership".to_string(),
368 source_type: "user".to_string(),
369 target_type: "order".to_string(),
370 forward_route_name: "orders-owned".to_string(),
371 reverse_route_name: "owner".to_string(),
372 description: None,
373 required_fields: None,
374 auth: None,
375 },
376 LinkDefinition {
377 link_type: "billing".to_string(),
378 source_type: "order".to_string(),
379 target_type: "invoice".to_string(),
380 forward_route_name: "invoices".to_string(),
381 reverse_route_name: "order".to_string(),
382 description: None,
383 required_fields: None,
384 auth: None,
385 },
386 ],
387 validation_rules: None,
388 events: None,
389 sinks: None,
390 });
391 let registry = LinkRouteRegistry::new(config.clone());
392 (config, registry)
393 }
394
395 #[test]
398 fn test_extractor_error_display_invalid_path() {
399 let err = ExtractorError::InvalidPath;
400 assert_eq!(err.to_string(), "Invalid path format");
401 }
402
403 #[test]
404 fn test_extractor_error_display_invalid_entity_id() {
405 let err = ExtractorError::InvalidEntityId;
406 assert_eq!(err.to_string(), "Invalid entity ID format");
407 }
408
409 #[test]
410 fn test_extractor_error_display_route_not_found() {
411 let err = ExtractorError::RouteNotFound("my-route".to_string());
412 assert_eq!(err.to_string(), "Route not found: my-route");
413 }
414
415 #[test]
416 fn test_extractor_error_display_link_not_found() {
417 let err = ExtractorError::LinkNotFound;
418 assert_eq!(err.to_string(), "Link not found");
419 }
420
421 #[test]
422 fn test_extractor_error_display_json_error() {
423 let err = ExtractorError::JsonError("bad json".to_string());
424 assert_eq!(err.to_string(), "JSON error: bad json");
425 }
426
427 #[test]
428 fn test_extractor_error_into_response_invalid_path_400() {
429 let err = ExtractorError::InvalidPath;
430 let response = err.into_response();
431 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
432 }
433
434 #[test]
435 fn test_extractor_error_into_response_invalid_entity_id_400() {
436 let err = ExtractorError::InvalidEntityId;
437 let response = err.into_response();
438 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
439 }
440
441 #[test]
442 fn test_extractor_error_into_response_route_not_found_404() {
443 let err = ExtractorError::RouteNotFound("test".to_string());
444 let response = err.into_response();
445 assert_eq!(response.status(), StatusCode::NOT_FOUND);
446 }
447
448 #[test]
449 fn test_extractor_error_into_response_link_not_found_404() {
450 let err = ExtractorError::LinkNotFound;
451 let response = err.into_response();
452 assert_eq!(response.status(), StatusCode::NOT_FOUND);
453 }
454
455 #[test]
456 fn test_extractor_error_into_response_json_error_400() {
457 let err = ExtractorError::JsonError("oops".to_string());
458 let response = err.into_response();
459 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
460 }
461
462 #[test]
465 fn test_link_extractor_forward_route() {
466 let (config, registry) = test_config_and_registry();
467 let id = Uuid::new_v4();
468 let result = LinkExtractor::from_path_and_registry(
469 ("users".to_string(), id, "orders-owned".to_string()),
470 ®istry,
471 &config,
472 );
473 assert!(result.is_ok());
474 let ext = result.expect("should succeed");
475 assert_eq!(ext.entity_type, "user");
476 assert_eq!(ext.entity_id, id);
477 assert_eq!(ext.link_definition.link_type, "ownership");
478 assert!(matches!(ext.direction, LinkDirection::Forward));
479 }
480
481 #[test]
482 fn test_link_extractor_reverse_route() {
483 let (config, registry) = test_config_and_registry();
484 let id = Uuid::new_v4();
485 let result = LinkExtractor::from_path_and_registry(
486 ("orders".to_string(), id, "owner".to_string()),
487 ®istry,
488 &config,
489 );
490 assert!(result.is_ok());
491 let ext = result.expect("should succeed");
492 assert_eq!(ext.entity_type, "order");
493 assert!(matches!(ext.direction, LinkDirection::Reverse));
494 }
495
496 #[test]
497 fn test_link_extractor_route_not_found() {
498 let (config, registry) = test_config_and_registry();
499 let id = Uuid::new_v4();
500 let result = LinkExtractor::from_path_and_registry(
501 ("users".to_string(), id, "nonexistent".to_string()),
502 ®istry,
503 &config,
504 );
505 assert!(result.is_err());
506 assert!(matches!(
507 result.unwrap_err(),
508 ExtractorError::RouteNotFound(_)
509 ));
510 }
511
512 #[test]
513 fn test_link_extractor_plural_to_singular_conversion() {
514 let (config, registry) = test_config_and_registry();
515 let id = Uuid::new_v4();
516 let result = LinkExtractor::from_path_and_registry(
517 ("users".to_string(), id, "orders-owned".to_string()),
518 ®istry,
519 &config,
520 );
521 let ext = result.expect("should succeed");
522 assert_eq!(ext.entity_type, "user");
524 }
525
526 #[test]
527 fn test_link_extractor_unknown_plural_used_as_is() {
528 let (config, registry) = test_config_and_registry();
529 let id = Uuid::new_v4();
530 let result = LinkExtractor::from_path_and_registry(
532 ("widgets".to_string(), id, "orders-owned".to_string()),
533 ®istry,
534 &config,
535 );
536 assert!(result.is_err());
538 }
539
540 #[test]
543 fn test_direct_link_extractor_forward() {
544 let (config, registry) = test_config_and_registry();
545 let source_id = Uuid::new_v4();
546 let target_id = Uuid::new_v4();
547 let result = DirectLinkExtractor::from_path(
548 (
549 "users".to_string(),
550 source_id,
551 "orders-owned".to_string(),
552 target_id,
553 ),
554 ®istry,
555 &config,
556 );
557 assert!(result.is_ok());
558 let ext = result.expect("should succeed");
559 assert_eq!(ext.source_type, "user");
560 assert_eq!(ext.source_id, source_id);
561 assert_eq!(ext.target_id, target_id);
562 assert_eq!(ext.target_type, "order"); assert!(matches!(ext.direction, LinkDirection::Forward));
564 }
565
566 #[test]
567 fn test_direct_link_extractor_reverse() {
568 let (config, registry) = test_config_and_registry();
569 let source_id = Uuid::new_v4();
570 let target_id = Uuid::new_v4();
571 let result = DirectLinkExtractor::from_path(
572 (
573 "orders".to_string(),
574 source_id,
575 "owner".to_string(),
576 target_id,
577 ),
578 ®istry,
579 &config,
580 );
581 assert!(result.is_ok());
582 let ext = result.expect("should succeed");
583 assert_eq!(ext.source_type, "order");
584 assert_eq!(ext.target_type, "user"); assert!(matches!(ext.direction, LinkDirection::Reverse));
586 }
587
588 #[test]
589 fn test_direct_link_extractor_route_not_found() {
590 let (config, registry) = test_config_and_registry();
591 let result = DirectLinkExtractor::from_path(
592 (
593 "users".to_string(),
594 Uuid::new_v4(),
595 "nope".to_string(),
596 Uuid::new_v4(),
597 ),
598 ®istry,
599 &config,
600 );
601 assert!(result.is_err());
602 assert!(matches!(
603 result.unwrap_err(),
604 ExtractorError::RouteNotFound(_)
605 ));
606 }
607
608 #[test]
611 fn test_recursive_too_few_segments_error() {
612 let (config, registry) = test_config_and_registry();
613 let result =
614 RecursiveLinkExtractor::from_segments(vec!["users".to_string()], ®istry, &config);
615 assert!(result.is_err());
616 assert!(matches!(result.unwrap_err(), ExtractorError::InvalidPath));
617 }
618
619 #[test]
620 fn test_recursive_entity_type_and_id() {
621 let (config, registry) = test_config_and_registry();
622 let id = Uuid::new_v4();
623 let result = RecursiveLinkExtractor::from_segments(
624 vec!["users".to_string(), id.to_string()],
625 ®istry,
626 &config,
627 );
628 assert!(result.is_ok());
629 let ext = result.expect("should succeed");
630 assert_eq!(ext.chain.len(), 1);
631 assert_eq!(ext.chain[0].entity_type, "user");
632 assert_eq!(ext.chain[0].entity_id, id);
633 }
634
635 #[test]
636 fn test_recursive_invalid_uuid_error() {
637 let (config, registry) = test_config_and_registry();
638 let result = RecursiveLinkExtractor::from_segments(
639 vec!["users".to_string(), "not-a-uuid".to_string()],
640 ®istry,
641 &config,
642 );
643 assert!(result.is_err());
644 assert!(matches!(
645 result.unwrap_err(),
646 ExtractorError::InvalidEntityId
647 ));
648 }
649
650 #[test]
651 fn test_recursive_unknown_entity_type_error() {
652 let (config, registry) = test_config_and_registry();
653 let result = RecursiveLinkExtractor::from_segments(
654 vec!["widgets".to_string(), Uuid::new_v4().to_string()],
655 ®istry,
656 &config,
657 );
658 assert!(result.is_err());
659 assert!(matches!(result.unwrap_err(), ExtractorError::InvalidPath));
660 }
661
662 #[test]
663 fn test_recursive_entity_id_route_forward() {
664 let (config, registry) = test_config_and_registry();
665 let user_id = Uuid::new_v4();
666 let result = RecursiveLinkExtractor::from_segments(
667 vec![
668 "users".to_string(),
669 user_id.to_string(),
670 "orders-owned".to_string(),
671 ],
672 ®istry,
673 &config,
674 );
675 assert!(result.is_ok());
676 let ext = result.expect("should succeed");
677 assert_eq!(ext.chain.len(), 2);
679 assert_eq!(ext.chain[0].entity_type, "user");
680 assert_eq!(ext.chain[0].entity_id, user_id);
681 assert_eq!(ext.chain[0].route_name.as_deref(), Some("orders-owned"));
682 assert_eq!(
683 ext.chain[0]
684 .link_definition
685 .as_ref()
686 .expect("should have link_def")
687 .link_type,
688 "ownership"
689 );
690 assert_eq!(ext.chain[1].entity_type, "order");
691 assert!(ext.chain[1].entity_id.is_nil()); }
693
694 #[test]
695 fn test_recursive_multi_level_chain() {
696 let (config, registry) = test_config_and_registry();
697 let user_id = Uuid::new_v4();
698 let order_id = Uuid::new_v4();
699 let result = RecursiveLinkExtractor::from_segments(
700 vec![
701 "users".to_string(),
702 user_id.to_string(),
703 "orders-owned".to_string(),
704 order_id.to_string(),
705 "invoices".to_string(),
706 ],
707 ®istry,
708 &config,
709 );
710 assert!(result.is_ok());
711 let ext = result.expect("should succeed");
712 assert_eq!(ext.chain.len(), 3);
714 assert_eq!(ext.chain[0].entity_type, "user");
715 assert_eq!(ext.chain[0].entity_id, user_id);
716 assert_eq!(ext.chain[1].entity_type, "order");
717 assert_eq!(ext.chain[1].entity_id, order_id);
718 assert_eq!(ext.chain[2].entity_type, "invoice");
719 assert!(ext.is_list); }
721
722 #[test]
723 fn test_recursive_multi_level_specific_item() {
724 let (config, registry) = test_config_and_registry();
725 let user_id = Uuid::new_v4();
726 let order_id = Uuid::new_v4();
727 let invoice_id = Uuid::new_v4();
728 let result = RecursiveLinkExtractor::from_segments(
729 vec![
730 "users".to_string(),
731 user_id.to_string(),
732 "orders-owned".to_string(),
733 order_id.to_string(),
734 "invoices".to_string(),
735 invoice_id.to_string(),
736 ],
737 ®istry,
738 &config,
739 );
740 assert!(result.is_ok());
741 let ext = result.expect("should succeed");
742 assert_eq!(ext.chain.len(), 3);
743 assert_eq!(ext.chain[2].entity_id, invoice_id);
744 assert!(!ext.is_list); }
746
747 #[test]
748 fn test_recursive_route_not_found_mid_chain() {
749 let (config, registry) = test_config_and_registry();
750 let result = RecursiveLinkExtractor::from_segments(
751 vec![
752 "users".to_string(),
753 Uuid::new_v4().to_string(),
754 "nonexistent-route".to_string(),
755 ],
756 ®istry,
757 &config,
758 );
759 assert!(result.is_err());
760 assert!(matches!(
761 result.unwrap_err(),
762 ExtractorError::RouteNotFound(_)
763 ));
764 }
765
766 #[test]
767 fn test_recursive_reverse_direction_propagation() {
768 let (config, registry) = test_config_and_registry();
769 let order_id = Uuid::new_v4();
770 let result = RecursiveLinkExtractor::from_segments(
772 vec![
773 "orders".to_string(),
774 order_id.to_string(),
775 "owner".to_string(),
776 ],
777 ®istry,
778 &config,
779 );
780 assert!(result.is_ok());
781 let ext = result.expect("should succeed");
782 assert_eq!(ext.chain.len(), 2);
783 assert_eq!(ext.chain[0].entity_type, "order");
784 assert!(matches!(
785 ext.chain[0].link_direction,
786 Some(LinkDirection::Reverse)
787 ));
788 assert_eq!(ext.chain[1].entity_type, "user");
790 }
791
792 #[test]
795 fn test_final_target_returns_last_segment() {
796 let (config, registry) = test_config_and_registry();
797 let user_id = Uuid::new_v4();
798 let ext = RecursiveLinkExtractor::from_segments(
799 vec![
800 "users".to_string(),
801 user_id.to_string(),
802 "orders-owned".to_string(),
803 ],
804 ®istry,
805 &config,
806 )
807 .expect("should succeed");
808 let (id, entity_type) = ext.final_target();
809 assert_eq!(entity_type, "order");
810 assert!(id.is_nil()); }
812
813 #[test]
814 fn test_final_link_def_returns_penultimate_link() {
815 let (config, registry) = test_config_and_registry();
816 let user_id = Uuid::new_v4();
817 let ext = RecursiveLinkExtractor::from_segments(
818 vec![
819 "users".to_string(),
820 user_id.to_string(),
821 "orders-owned".to_string(),
822 ],
823 ®istry,
824 &config,
825 )
826 .expect("should succeed");
827 let link_def = ext.final_link_def();
828 assert!(link_def.is_some());
829 assert_eq!(link_def.expect("should have link").link_type, "ownership");
830 }
831
832 #[test]
833 fn test_final_link_def_single_segment_returns_none() {
834 let (config, registry) = test_config_and_registry();
835 let ext = RecursiveLinkExtractor::from_segments(
836 vec!["users".to_string(), Uuid::new_v4().to_string()],
837 ®istry,
838 &config,
839 )
840 .expect("should succeed");
841 assert!(ext.final_link_def().is_none());
842 }
843
844 #[test]
845 fn test_penultimate_segment_returns_correct() {
846 let (config, registry) = test_config_and_registry();
847 let user_id = Uuid::new_v4();
848 let ext = RecursiveLinkExtractor::from_segments(
849 vec![
850 "users".to_string(),
851 user_id.to_string(),
852 "orders-owned".to_string(),
853 ],
854 ®istry,
855 &config,
856 )
857 .expect("should succeed");
858 let pen = ext.penultimate_segment();
859 assert!(pen.is_some());
860 assert_eq!(pen.expect("should exist").entity_type, "user");
861 assert_eq!(pen.expect("should exist").entity_id, user_id);
862 }
863
864 #[test]
865 fn test_penultimate_segment_single_segment_returns_none() {
866 let (config, registry) = test_config_and_registry();
867 let ext = RecursiveLinkExtractor::from_segments(
868 vec!["users".to_string(), Uuid::new_v4().to_string()],
869 ®istry,
870 &config,
871 )
872 .expect("should succeed");
873 assert!(ext.penultimate_segment().is_none());
874 }
875}