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//! - Extract tenant IDs from headers
6//! - Parse link routes and resolve definitions
7
8use axum::extract::{FromRequest, Path, Request};
9use axum::http::StatusCode;
10use axum::response::{IntoResponse, Response};
11use axum::Json;
12use serde::{Deserialize, Serialize};
13use std::sync::Arc;
14use uuid::Uuid;
15
16use crate::config::LinksConfig;
17use crate::core::{EntityReference, Link, LinkDefinition};
18use crate::links::registry::{LinkDirection, LinkRouteRegistry};
19
20/// Extract tenant ID from request headers
21///
22/// Expected header: `X-Tenant-ID: <uuid>`
23pub fn extract_tenant_id(headers: &axum::http::HeaderMap) -> Result<Uuid, ExtractorError> {
24    let tenant_id_str = headers
25        .get("X-Tenant-ID")
26        .ok_or(ExtractorError::MissingTenantId)?
27        .to_str()
28        .map_err(|_| ExtractorError::InvalidTenantId)?;
29
30    Uuid::parse_str(tenant_id_str).map_err(|_| ExtractorError::InvalidTenantId)
31}
32
33/// Errors that can occur during extraction
34#[derive(Debug, Clone)]
35pub enum ExtractorError {
36    MissingTenantId,
37    InvalidTenantId,
38    InvalidPath,
39    InvalidEntityId,
40    RouteNotFound(String),
41    LinkNotFound,
42    JsonError(String),
43}
44
45impl std::fmt::Display for ExtractorError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            ExtractorError::MissingTenantId => write!(f, "Missing X-Tenant-ID header"),
49            ExtractorError::InvalidTenantId => write!(f, "Invalid tenant ID format"),
50            ExtractorError::InvalidPath => write!(f, "Invalid path format"),
51            ExtractorError::InvalidEntityId => write!(f, "Invalid entity ID format"),
52            ExtractorError::RouteNotFound(route) => write!(f, "Route not found: {}", route),
53            ExtractorError::LinkNotFound => write!(f, "Link not found"),
54            ExtractorError::JsonError(msg) => write!(f, "JSON error: {}", msg),
55        }
56    }
57}
58
59impl std::error::Error for ExtractorError {}
60
61impl IntoResponse for ExtractorError {
62    fn into_response(self) -> Response {
63        let (status, message) = match self {
64            ExtractorError::MissingTenantId => (StatusCode::BAD_REQUEST, self.to_string()),
65            ExtractorError::InvalidTenantId => (StatusCode::BAD_REQUEST, self.to_string()),
66            ExtractorError::InvalidPath => (StatusCode::BAD_REQUEST, self.to_string()),
67            ExtractorError::InvalidEntityId => (StatusCode::BAD_REQUEST, self.to_string()),
68            ExtractorError::RouteNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
69            ExtractorError::LinkNotFound => (StatusCode::NOT_FOUND, self.to_string()),
70            ExtractorError::JsonError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
71        };
72
73        (status, Json(serde_json::json!({ "error": message }))).into_response()
74    }
75}
76
77/// Extractor for link information from path
78///
79/// Automatically parses the path and resolves link definitions.
80/// Supports both forward and reverse navigation.
81#[derive(Debug, Clone)]
82pub struct LinkExtractor {
83    pub tenant_id: Uuid,
84    pub entity_id: Uuid,
85    pub entity_type: String,
86    pub link_definition: LinkDefinition,
87    pub direction: LinkDirection,
88}
89
90impl LinkExtractor {
91    /// Parse a link route path
92    ///
93    /// Expected format: `/{entity_type}/{entity_id}/{route_name}`
94    /// Example: `/users/123.../cars-owned`
95    pub fn from_path_and_registry(
96        path_parts: (String, Uuid, String),
97        registry: &LinkRouteRegistry,
98        config: &LinksConfig,
99        tenant_id: Uuid,
100    ) -> Result<Self, ExtractorError> {
101        let (entity_type_plural, entity_id, route_name) = path_parts;
102
103        // Convert plural to singular
104        let entity_type = config
105            .entities
106            .iter()
107            .find(|e| e.plural == entity_type_plural)
108            .map(|e| e.singular.clone())
109            .unwrap_or(entity_type_plural);
110
111        // Resolve the route
112        let (link_definition, direction) = registry
113            .resolve_route(&entity_type, &route_name)
114            .map_err(|_| ExtractorError::RouteNotFound(route_name.clone()))?;
115
116        Ok(Self {
117            tenant_id,
118            entity_id,
119            entity_type,
120            link_definition,
121            direction,
122        })
123    }
124}
125
126/// Extractor for direct link creation/deletion
127///
128/// Format: `/{source_type}/{source_id}/{link_type}/{target_type}/{target_id}`
129#[derive(Debug, Clone)]
130pub struct DirectLinkExtractor {
131    pub tenant_id: Uuid,
132    pub link_type: String,
133    pub source: EntityReference,
134    pub target: EntityReference,
135    pub link_definition: Option<LinkDefinition>,
136}
137
138impl DirectLinkExtractor {
139    /// Parse a direct link path
140    pub fn from_path(
141        path_parts: (String, Uuid, String, String, Uuid),
142        config: &LinksConfig,
143        tenant_id: Uuid,
144    ) -> Result<Self, ExtractorError> {
145        let (source_type_plural, source_id, link_type, target_type_plural, target_id) = path_parts;
146
147        // Convert plurals to singulars
148        let source_type = config
149            .entities
150            .iter()
151            .find(|e| e.plural == source_type_plural)
152            .map(|e| e.singular.clone())
153            .unwrap_or(source_type_plural);
154
155        let target_type = config
156            .entities
157            .iter()
158            .find(|e| e.plural == target_type_plural)
159            .map(|e| e.singular.clone())
160            .unwrap_or(target_type_plural);
161
162        let source = EntityReference::new(source_id, source_type.clone());
163        let target = EntityReference::new(target_id, target_type.clone());
164
165        // Try to find the link definition
166        let link_definition = config
167            .links
168            .iter()
169            .find(|def| {
170                def.link_type == link_type
171                    && def.source_type == source_type
172                    && def.target_type == target_type
173            })
174            .cloned();
175
176        Ok(Self {
177            tenant_id,
178            link_type,
179            source,
180            target,
181            link_definition,
182        })
183    }
184}