this/links/
handlers.rs

1//! HTTP handlers for link operations
2//!
3//! This module provides generic handlers that work with any entity types.
4//! All handlers are completely entity-agnostic.
5
6use axum::{
7    extract::{Path, State},
8    http::{HeaderMap, StatusCode},
9    response::{IntoResponse, Response},
10    Json,
11};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::sync::Arc;
16use uuid::Uuid;
17
18use crate::config::LinksConfig;
19use crate::core::extractors::{
20    extract_tenant_id, DirectLinkExtractor, ExtractorError, LinkExtractor,
21};
22use crate::core::{EntityFetcher, EntityReference, Link, LinkDefinition, LinkService};
23use crate::links::registry::{LinkDirection, LinkRouteRegistry};
24
25/// Application state shared across handlers
26#[derive(Clone)]
27pub struct AppState {
28    pub link_service: Arc<dyn LinkService>,
29    pub config: Arc<LinksConfig>,
30    pub registry: Arc<LinkRouteRegistry>,
31    /// Entity fetchers for enriching links with full entity data
32    pub entity_fetchers: Arc<HashMap<String, Arc<dyn EntityFetcher>>>,
33}
34
35impl AppState {
36    /// Get the authorization policy for a link operation
37    ///
38    /// Returns the link-specific auth policy if defined, otherwise returns None
39    /// to indicate that entity-level permissions should be used.
40    ///
41    /// # Arguments
42    /// * `link_definition` - The link definition to check
43    /// * `operation` - The operation type: "list", "create", or "delete"
44    pub fn get_link_auth_policy(
45        link_definition: &LinkDefinition,
46        operation: &str,
47    ) -> Option<String> {
48        link_definition.auth.as_ref().map(|auth| match operation {
49            "list" => auth.list.clone(),
50            "get" => auth.get.clone(),
51            "create" => auth.create.clone(),
52            "update" => auth.update.clone(),
53            "delete" => auth.delete.clone(),
54            _ => "authenticated".to_string(),
55        })
56    }
57}
58
59/// Response for list links endpoint
60#[derive(Debug, Serialize)]
61pub struct ListLinksResponse {
62    pub links: Vec<Link>,
63    pub count: usize,
64    pub link_type: String,
65    pub direction: String,
66    pub description: Option<String>,
67}
68
69/// Link with full entity data instead of just references
70///
71/// This enriched version includes the complete source and target entities
72/// as JSON, avoiding the need for additional API calls.
73///
74/// Depending on the context:
75/// - From source route (e.g., /orders/{id}/invoices): only `target` is populated
76/// - From target route (reverse): only `source` is populated
77/// - Direct link access (e.g., /links/{id}): both `source` and `target` are populated
78#[derive(Debug, Serialize)]
79pub struct EnrichedLink {
80    /// Unique identifier for this link
81    pub id: Uuid,
82
83    /// Tenant ID for multi-tenant isolation
84    pub tenant_id: Uuid,
85
86    /// The type of relationship (e.g., "has_invoice", "payment")
87    pub link_type: String,
88
89    /// Full source entity as JSON (omitted when querying from source)
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub source: Option<serde_json::Value>,
92
93    /// Full target entity as JSON (omitted when querying from target)
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub target: Option<serde_json::Value>,
96
97    /// Optional metadata for the relationship
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub metadata: Option<serde_json::Value>,
100
101    /// When this link was created
102    pub created_at: DateTime<Utc>,
103
104    /// When this link was last updated
105    pub updated_at: DateTime<Utc>,
106}
107
108/// Response for enriched list links endpoint
109#[derive(Debug, Serialize)]
110pub struct EnrichedListLinksResponse {
111    pub links: Vec<EnrichedLink>,
112    pub count: usize,
113    pub link_type: String,
114    pub direction: String,
115    pub description: Option<String>,
116}
117
118/// Request body for creating a link
119#[derive(Debug, Deserialize)]
120pub struct CreateLinkRequest {
121    pub metadata: Option<serde_json::Value>,
122}
123
124/// Context for link enrichment
125///
126/// Determines which entities should be fetched and included in the response
127#[derive(Debug, Clone, Copy)]
128enum EnrichmentContext {
129    /// Query from source entity (e.g., /orders/{id}/invoices)
130    /// Only target entities are included
131    FromSource,
132
133    /// Query from target entity (reverse navigation)
134    /// Only source entities are included
135    FromTarget,
136
137    /// Direct link access (e.g., /links/{id})
138    /// Both source and target entities are included
139    DirectLink,
140}
141
142/// List links using named routes (forward or reverse)
143///
144/// GET /{entity_type}/{entity_id}/{route_name}
145///
146/// Examples:
147/// - GET /users/{id}/cars-owned  → Forward navigation
148/// - GET /cars/{id}/users-owners → Reverse navigation
149///
150/// This endpoint automatically enriches links with full entity data.
151pub async fn list_links(
152    State(state): State<AppState>,
153    Path((entity_type_plural, entity_id, route_name)): Path<(String, Uuid, String)>,
154    headers: HeaderMap,
155) -> Result<Json<EnrichedListLinksResponse>, ExtractorError> {
156    let tenant_id = extract_tenant_id(&headers)?;
157
158    let extractor = LinkExtractor::from_path_and_registry(
159        (entity_type_plural, entity_id, route_name),
160        &state.registry,
161        &state.config,
162        tenant_id,
163    )?;
164
165    // TODO: Check authorization - use link-specific auth if available, fallback to entity auth
166    // if let Some(link_auth) = &extractor.link_definition.auth {
167    //     check_auth_policy(&headers, &link_auth.list, &extractor)?;
168    // } else {
169    //     // Fallback to entity-level link permissions
170    //     check_entity_link_auth(&headers, &extractor.entity_type, "list_links")?;
171    // }
172
173    // Query links based on direction
174    let links = match extractor.direction {
175        LinkDirection::Forward => {
176            let source = EntityReference::new(extractor.entity_id, extractor.entity_type);
177            state
178                .link_service
179                .find_by_source(
180                    &tenant_id,
181                    &extractor.entity_id,
182                    &source.entity_type,
183                    Some(&extractor.link_definition.link_type),
184                    Some(&extractor.link_definition.target_type),
185                )
186                .await
187                .map_err(|e| ExtractorError::JsonError(e.to_string()))?
188        }
189        LinkDirection::Reverse => {
190            let target = EntityReference::new(extractor.entity_id, extractor.entity_type);
191            state
192                .link_service
193                .find_by_target(
194                    &tenant_id,
195                    &extractor.entity_id,
196                    &target.entity_type,
197                    Some(&extractor.link_definition.link_type),
198                    Some(&extractor.link_definition.source_type),
199                )
200                .await
201                .map_err(|e| ExtractorError::JsonError(e.to_string()))?
202        }
203    };
204
205    // Determine enrichment context based on direction
206    let context = match extractor.direction {
207        LinkDirection::Forward => EnrichmentContext::FromSource,
208        LinkDirection::Reverse => EnrichmentContext::FromTarget,
209    };
210
211    // Enrich links with full entity data (only the relevant side)
212    let enriched_links = enrich_links_with_entities(&state, links, &tenant_id, context).await?;
213
214    Ok(Json(EnrichedListLinksResponse {
215        count: enriched_links.len(),
216        links: enriched_links,
217        link_type: extractor.link_definition.link_type,
218        direction: format!("{:?}", extractor.direction),
219        description: extractor.link_definition.description,
220    }))
221}
222
223/// Helper function to enrich links with full entity data
224///
225/// Depending on the context, only the necessary entities are fetched:
226/// - FromSource: only target entities
227/// - FromTarget: only source entities  
228/// - DirectLink: both source and target entities
229async fn enrich_links_with_entities(
230    state: &AppState,
231    links: Vec<Link>,
232    tenant_id: &Uuid,
233    context: EnrichmentContext,
234) -> Result<Vec<EnrichedLink>, ExtractorError> {
235    let mut enriched = Vec::new();
236
237    for link in links {
238        // Fetch source entity only if needed
239        let source_entity = match context {
240            EnrichmentContext::FromSource => None, // Already known from URL
241            EnrichmentContext::FromTarget | EnrichmentContext::DirectLink => Some(
242                fetch_entity_by_type(state, tenant_id, &link.source.entity_type, &link.source.id)
243                    .await?,
244            ),
245        };
246
247        // Fetch target entity only if needed
248        let target_entity = match context {
249            EnrichmentContext::FromTarget => None, // Already known from URL
250            EnrichmentContext::FromSource | EnrichmentContext::DirectLink => Some(
251                fetch_entity_by_type(state, tenant_id, &link.target.entity_type, &link.target.id)
252                    .await?,
253            ),
254        };
255
256        enriched.push(EnrichedLink {
257            id: link.id,
258            tenant_id: link.tenant_id,
259            link_type: link.link_type,
260            source: source_entity,
261            target: target_entity,
262            metadata: link.metadata,
263            created_at: link.created_at,
264            updated_at: link.updated_at,
265        });
266    }
267
268    Ok(enriched)
269}
270
271/// Fetch an entity dynamically by type
272async fn fetch_entity_by_type(
273    state: &AppState,
274    tenant_id: &Uuid,
275    entity_type: &str,
276    entity_id: &Uuid,
277) -> Result<serde_json::Value, ExtractorError> {
278    // Look up the fetcher for this entity type
279    let fetcher = state.entity_fetchers.get(entity_type).ok_or_else(|| {
280        ExtractorError::JsonError(format!(
281            "No entity fetcher registered for type: {}",
282            entity_type
283        ))
284    })?;
285
286    // Fetch the entity as JSON
287    fetcher
288        .fetch_as_json(tenant_id, entity_id)
289        .await
290        .map_err(|e| ExtractorError::JsonError(format!("Failed to fetch entity: {}", e)))
291}
292
293/// Get a specific link by ID
294///
295/// GET /links/{link_id}
296///
297/// Example:
298/// - GET /links/abc-123-def-456
299///
300/// This endpoint returns the link enriched with BOTH source and target entities,
301/// since the caller doesn't know which entities are involved.
302pub async fn get_link(
303    State(state): State<AppState>,
304    Path(link_id): Path<Uuid>,
305    headers: HeaderMap,
306) -> Result<Response, ExtractorError> {
307    let tenant_id = extract_tenant_id(&headers)?;
308
309    // Get the link
310    let link = state
311        .link_service
312        .get(&tenant_id, &link_id)
313        .await
314        .map_err(|e| ExtractorError::JsonError(e.to_string()))?
315        .ok_or_else(|| ExtractorError::LinkNotFound)?;
316
317    // Find the link definition to check permissions
318    let _link_def = state.config.find_link_definition(
319        &link.link_type,
320        &link.source.entity_type,
321        &link.target.entity_type,
322    );
323
324    // TODO: Check authorization for getting a link
325    // if let Some(def) = link_def {
326    //     if let Some(link_auth) = &def.auth {
327    //         check_auth_policy(&headers, &link_auth.get, &state)?;
328    //     }
329    // }
330
331    // Enrich with both source and target entities (DirectLink context)
332    let enriched_links = enrich_links_with_entities(
333        &state,
334        vec![link],
335        &tenant_id,
336        EnrichmentContext::DirectLink,
337    )
338    .await?;
339
340    let enriched_link = enriched_links
341        .into_iter()
342        .next()
343        .ok_or_else(|| ExtractorError::LinkNotFound)?;
344
345    Ok(Json(enriched_link).into_response())
346}
347
348/// Create a link using direct path
349///
350/// POST /{source_type}/{source_id}/{link_type}/{target_type}/{target_id}
351///
352/// Example:
353/// - POST /users/123.../owner/cars/456...
354pub async fn create_link(
355    State(state): State<AppState>,
356    Path((source_type_plural, source_id, link_type, target_type_plural, target_id)): Path<(
357        String,
358        Uuid,
359        String,
360        String,
361        Uuid,
362    )>,
363    headers: HeaderMap,
364    Json(payload): Json<CreateLinkRequest>,
365) -> Result<Response, ExtractorError> {
366    let tenant_id = extract_tenant_id(&headers)?;
367
368    let extractor = DirectLinkExtractor::from_path(
369        (
370            source_type_plural,
371            source_id,
372            link_type.clone(),
373            target_type_plural,
374            target_id,
375        ),
376        &state.config,
377        tenant_id,
378    )?;
379
380    // TODO: Check authorization for link creation
381    // if let Some(link_def) = &extractor.link_definition {
382    //     if let Some(link_auth) = &link_def.auth {
383    //         check_auth_policy(&headers, &link_auth.create, &extractor)?;
384    //     } else {
385    //         // Fallback to entity-level link permissions
386    //         check_entity_link_auth(&headers, &extractor.source.entity_type, "create_link")?;
387    //     }
388    // }
389
390    // Validate the link definition exists
391    if extractor.link_definition.is_none() {
392        return Err(ExtractorError::RouteNotFound(format!(
393            "No link definition found for {} -> {} via {}",
394            extractor.source.entity_type, extractor.target.entity_type, link_type
395        )));
396    }
397
398    // Create the link
399    let link = state
400        .link_service
401        .create(
402            &tenant_id,
403            &link_type,
404            extractor.source,
405            extractor.target,
406            payload.metadata,
407        )
408        .await
409        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
410
411    Ok((StatusCode::CREATED, Json(link)).into_response())
412}
413
414/// Update a link's metadata using direct path
415///
416/// PUT/PATCH /{source_type}/{source_id}/{link_type}/{target_type}/{target_id}
417///
418/// Example:
419/// - PUT /users/123.../worker/companies/456...
420/// - Body: { "metadata": { "role": "Senior Developer", "promotion_date": "2024-06-01" } }
421pub async fn update_link(
422    State(state): State<AppState>,
423    Path((source_type_plural, source_id, link_type, target_type_plural, target_id)): Path<(
424        String,
425        Uuid,
426        String,
427        String,
428        Uuid,
429    )>,
430    headers: HeaderMap,
431    Json(payload): Json<CreateLinkRequest>,
432) -> Result<Response, ExtractorError> {
433    let tenant_id = extract_tenant_id(&headers)?;
434
435    let extractor = DirectLinkExtractor::from_path(
436        (
437            source_type_plural,
438            source_id,
439            link_type.clone(),
440            target_type_plural,
441            target_id,
442        ),
443        &state.config,
444        tenant_id,
445    )?;
446
447    // TODO: Check authorization for link update
448    // if let Some(link_def) = &extractor.link_definition {
449    //     if let Some(link_auth) = &link_def.auth {
450    //         check_auth_policy(&headers, &link_auth.update, &extractor)?;
451    //     } else {
452    //         // Fallback to entity-level link permissions
453    //         check_entity_link_auth(&headers, &extractor.source.entity_type, "update_link")?;
454    //     }
455    // }
456
457    // Find the existing link
458    let existing_links = state
459        .link_service
460        .find_by_source(
461            &tenant_id,
462            &extractor.source.id,
463            &extractor.source.entity_type,
464            Some(&link_type),
465            Some(&extractor.target.entity_type),
466        )
467        .await
468        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
469
470    let existing_link = existing_links
471        .into_iter()
472        .find(|link| link.target.id == extractor.target.id)
473        .ok_or_else(|| ExtractorError::RouteNotFound("Link not found".to_string()))?;
474
475    // Update the link
476    let updated_link = state
477        .link_service
478        .update(&tenant_id, &existing_link.id, payload.metadata)
479        .await
480        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
481
482    Ok(Json(updated_link).into_response())
483}
484
485/// Delete a link using direct path
486///
487/// DELETE /{source_type}/{source_id}/{link_type}/{target_type}/{target_id}
488///
489/// Example:
490/// - DELETE /users/123.../owner/cars/456...
491pub async fn delete_link(
492    State(state): State<AppState>,
493    Path((source_type_plural, source_id, link_type, target_type_plural, target_id)): Path<(
494        String,
495        Uuid,
496        String,
497        String,
498        Uuid,
499    )>,
500    headers: HeaderMap,
501) -> Result<Response, ExtractorError> {
502    let tenant_id = extract_tenant_id(&headers)?;
503
504    let extractor = DirectLinkExtractor::from_path(
505        (
506            source_type_plural,
507            source_id,
508            link_type.clone(),
509            target_type_plural,
510            target_id,
511        ),
512        &state.config,
513        tenant_id,
514    )?;
515
516    // TODO: Check authorization for link deletion
517    // if let Some(link_def) = &extractor.link_definition {
518    //     if let Some(link_auth) = &link_def.auth {
519    //         check_auth_policy(&headers, &link_auth.delete, &extractor)?;
520    //     } else {
521    //         // Fallback to entity-level link permissions
522    //         check_entity_link_auth(&headers, &extractor.source.entity_type, "delete_link")?;
523    //     }
524    // }
525
526    // Delete the link
527    state
528        .link_service
529        .delete(&tenant_id, &extractor.source.id)
530        .await
531        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
532
533    Ok((StatusCode::NO_CONTENT, ()).into_response())
534}
535
536/// Response for introspection endpoint
537#[derive(Debug, Serialize)]
538pub struct IntrospectionResponse {
539    pub entity_type: String,
540    pub entity_id: Uuid,
541    pub available_routes: Vec<RouteDescription>,
542}
543
544/// Description of an available route
545#[derive(Debug, Serialize)]
546pub struct RouteDescription {
547    pub path: String,
548    pub method: String,
549    pub link_type: String,
550    pub direction: String,
551    pub connected_to: String,
552    pub description: Option<String>,
553}
554
555/// Introspection: List all available link routes for an entity
556///
557/// GET /{entity_type}/{entity_id}/links
558///
559/// Example:
560/// - GET /users/123.../links
561pub async fn list_available_links(
562    State(state): State<AppState>,
563    Path((entity_type_plural, entity_id)): Path<(String, Uuid)>,
564    headers: HeaderMap,
565) -> Result<Json<IntrospectionResponse>, ExtractorError> {
566    let _tenant_id = extract_tenant_id(&headers)?;
567
568    // Convert plural to singular
569    let entity_type = state
570        .config
571        .entities
572        .iter()
573        .find(|e| e.plural == entity_type_plural)
574        .map(|e| e.singular.clone())
575        .unwrap_or_else(|| entity_type_plural.clone());
576
577    // Get all routes for this entity type
578    let routes = state.registry.list_routes_for_entity(&entity_type);
579
580    let available_routes = routes
581        .iter()
582        .map(|r| RouteDescription {
583            path: format!("/{}/{}/{}", entity_type_plural, entity_id, r.route_name),
584            method: "GET".to_string(),
585            link_type: r.link_type.clone(),
586            direction: format!("{:?}", r.direction),
587            connected_to: r.connected_to.clone(),
588            description: r.description.clone(),
589        })
590        .collect();
591
592    Ok(Json(IntrospectionResponse {
593        entity_type,
594        entity_id,
595        available_routes,
596    }))
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use crate::config::EntityConfig;
603    use crate::core::LinkDefinition;
604    use crate::links::service::InMemoryLinkService;
605
606    fn create_test_state() -> AppState {
607        let config = Arc::new(LinksConfig {
608            entities: vec![
609                EntityConfig {
610                    singular: "user".to_string(),
611                    plural: "users".to_string(),
612                    auth: crate::config::EntityAuthConfig::default(),
613                },
614                EntityConfig {
615                    singular: "car".to_string(),
616                    plural: "cars".to_string(),
617                    auth: crate::config::EntityAuthConfig::default(),
618                },
619            ],
620            links: vec![LinkDefinition {
621                link_type: "owner".to_string(),
622                source_type: "user".to_string(),
623                target_type: "car".to_string(),
624                forward_route_name: "cars-owned".to_string(),
625                reverse_route_name: "users-owners".to_string(),
626                description: Some("User owns a car".to_string()),
627                required_fields: None,
628                auth: None,
629            }],
630            validation_rules: None,
631        });
632
633        let registry = Arc::new(LinkRouteRegistry::new(config.clone()));
634        let link_service: Arc<dyn LinkService> = Arc::new(InMemoryLinkService::new());
635
636        AppState {
637            link_service,
638            config,
639            registry,
640            entity_fetchers: Arc::new(HashMap::new()),
641        }
642    }
643
644    #[test]
645    fn test_state_creation() {
646        let state = create_test_state();
647        assert_eq!(state.config.entities.len(), 2);
648        assert_eq!(state.config.links.len(), 1);
649    }
650}