this/core/
extractors.rs

1//! Axum extractors for entities and links
2//!
3//! This module provides HTTP extractors that automatically:
4//! - Deserialize and validate entities from request bodies
5//! - Parse link routes and resolve definitions
6
7use axum::http::StatusCode;
8use axum::response::{IntoResponse, Response};
9use axum::Json;
10use uuid::Uuid;
11
12use crate::config::LinksConfig;
13use crate::core::LinkDefinition;
14use crate::links::registry::{LinkDirection, LinkRouteRegistry};
15
16/// Errors that can occur during extraction
17#[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/// Extractor for link information from path
55///
56/// Automatically parses the path and resolves link definitions.
57/// Supports both forward and reverse navigation.
58#[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    /// Parse a link route path
68    ///
69    /// Expected format: `/{entity_type}/{entity_id}/{route_name}`
70    /// Example: `/users/123.../cars-owned`
71    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        // Convert plural to singular
79        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        // Resolve the route
87        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/// Extractor for direct link creation/deletion/update
101///
102/// Format: `/{source_type}/{source_id}/{route_name}/{target_id}`
103/// Example: `/users/123.../cars-owned/456...`
104///
105/// This uses the route_name (e.g., "cars-owned") instead of link_type (e.g., "owner")
106/// to provide more semantic and RESTful URLs.
107#[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    /// Parse a direct link path using route_name
119    ///
120    /// path_parts = (source_type_plural, source_id, route_name, target_id)
121    ///
122    /// The route_name is resolved to a link definition using the LinkRouteRegistry,
123    /// which handles both forward and reverse navigation automatically.
124    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        // Convert plural to singular
132        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        // Resolve the route to get link definition and direction
140        let (link_definition, direction) = registry
141            .resolve_route(&source_type, &route_name)
142            .map_err(|_| ExtractorError::RouteNotFound(route_name.clone()))?;
143
144        // Determine target type based on direction
145        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}