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(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(ExtractorError::LinkNotFound)?;
344
345    Ok(Json(enriched_link).into_response())
346}
347
348/// Get a specific link by source, route_name, and target
349///
350/// GET /{source_type}/{source_id}/{route_name}/{target_id}
351///
352/// Example:
353/// - GET /orders/123.../invoices/456...
354/// - GET /users/123.../cars-owned/456...
355///
356/// This endpoint returns the specific link enriched with BOTH source and target entities.
357pub async fn get_link_by_route(
358    State(state): State<AppState>,
359    Path((source_type_plural, source_id, route_name, target_id)): Path<(
360        String,
361        Uuid,
362        String,
363        Uuid,
364    )>,
365    headers: HeaderMap,
366) -> Result<Response, ExtractorError> {
367    let tenant_id = extract_tenant_id(&headers)?;
368
369    let extractor = DirectLinkExtractor::from_path(
370        (source_type_plural, source_id, route_name, target_id),
371        &state.registry,
372        &state.config,
373        tenant_id,
374    )?;
375
376    // TODO: Check authorization for getting a link
377    // if let Some(link_auth) = &extractor.link_definition.auth {
378    //     check_auth_policy(&headers, &link_auth.get, &extractor)?;
379    // }
380
381    // Find the specific link
382    let existing_links = state
383        .link_service
384        .find_by_source(
385            &tenant_id,
386            &extractor.source.id,
387            &extractor.source.entity_type,
388            Some(&extractor.link_definition.link_type),
389            Some(&extractor.target.entity_type),
390        )
391        .await
392        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
393
394    let link = existing_links
395        .into_iter()
396        .find(|link| link.target.id == extractor.target.id)
397        .ok_or(ExtractorError::LinkNotFound)?;
398
399    // Enrich with both source and target entities (DirectLink context)
400    let enriched_links = enrich_links_with_entities(
401        &state,
402        vec![link],
403        &tenant_id,
404        EnrichmentContext::DirectLink,
405    )
406    .await?;
407
408    let enriched_link = enriched_links
409        .into_iter()
410        .next()
411        .ok_or(ExtractorError::LinkNotFound)?;
412
413    Ok(Json(enriched_link).into_response())
414}
415
416/// Create a link using route name
417///
418/// POST /{source_type}/{source_id}/{route_name}/{target_id}
419///
420/// Example:
421/// - POST /users/123.../cars-owned/456...
422/// - POST /users/123.../cars-driven/456...
423///
424/// This uses the semantic route_name instead of the technical link_type,
425/// making URLs more intuitive and RESTful.
426pub async fn create_link(
427    State(state): State<AppState>,
428    Path((source_type_plural, source_id, route_name, target_id)): Path<(
429        String,
430        Uuid,
431        String,
432        Uuid,
433    )>,
434    headers: HeaderMap,
435    Json(payload): Json<CreateLinkRequest>,
436) -> Result<Response, ExtractorError> {
437    let tenant_id = extract_tenant_id(&headers)?;
438
439    let extractor = DirectLinkExtractor::from_path(
440        (source_type_plural, source_id, route_name, target_id),
441        &state.registry,
442        &state.config,
443        tenant_id,
444    )?;
445
446    // TODO: Check authorization for link creation
447    // if let Some(link_auth) = &extractor.link_definition.auth {
448    //     check_auth_policy(&headers, &link_auth.create, &extractor)?;
449    // }
450
451    // Create the link using the link_type from the resolved definition
452    let link = state
453        .link_service
454        .create(
455            &tenant_id,
456            &extractor.link_definition.link_type,
457            extractor.source,
458            extractor.target,
459            payload.metadata,
460        )
461        .await
462        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
463
464    Ok((StatusCode::CREATED, Json(link)).into_response())
465}
466
467/// Update a link's metadata using route name
468///
469/// PUT/PATCH /{source_type}/{source_id}/{route_name}/{target_id}
470///
471/// Example:
472/// - PUT /users/123.../companies-work/456...
473/// - Body: { "metadata": { "role": "Senior Developer", "promotion_date": "2024-06-01" } }
474///
475/// This uses the semantic route_name for consistency with other operations.
476pub async fn update_link(
477    State(state): State<AppState>,
478    Path((source_type_plural, source_id, route_name, target_id)): Path<(
479        String,
480        Uuid,
481        String,
482        Uuid,
483    )>,
484    headers: HeaderMap,
485    Json(payload): Json<CreateLinkRequest>,
486) -> Result<Response, ExtractorError> {
487    let tenant_id = extract_tenant_id(&headers)?;
488
489    let extractor = DirectLinkExtractor::from_path(
490        (source_type_plural, source_id, route_name, target_id),
491        &state.registry,
492        &state.config,
493        tenant_id,
494    )?;
495
496    // TODO: Check authorization for link update
497    // if let Some(link_auth) = &extractor.link_definition.auth {
498    //     check_auth_policy(&headers, &link_auth.update, &extractor)?;
499    // }
500
501    // Find the existing link
502    let existing_links = state
503        .link_service
504        .find_by_source(
505            &tenant_id,
506            &extractor.source.id,
507            &extractor.source.entity_type,
508            Some(&extractor.link_definition.link_type),
509            Some(&extractor.target.entity_type),
510        )
511        .await
512        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
513
514    let existing_link = existing_links
515        .into_iter()
516        .find(|link| link.target.id == extractor.target.id)
517        .ok_or_else(|| ExtractorError::RouteNotFound("Link not found".to_string()))?;
518
519    // Update the link
520    let updated_link = state
521        .link_service
522        .update(&tenant_id, &existing_link.id, payload.metadata)
523        .await
524        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
525
526    Ok(Json(updated_link).into_response())
527}
528
529/// Delete a link using route name
530///
531/// DELETE /{source_type}/{source_id}/{route_name}/{target_id}
532///
533/// Example:
534/// - DELETE /users/123.../cars-owned/456...
535/// - DELETE /users/123.../cars-driven/456...
536///
537/// This uses the semantic route_name for consistency with other operations.
538pub async fn delete_link(
539    State(state): State<AppState>,
540    Path((source_type_plural, source_id, route_name, target_id)): Path<(
541        String,
542        Uuid,
543        String,
544        Uuid,
545    )>,
546    headers: HeaderMap,
547) -> Result<Response, ExtractorError> {
548    let tenant_id = extract_tenant_id(&headers)?;
549
550    let extractor = DirectLinkExtractor::from_path(
551        (source_type_plural, source_id, route_name, target_id),
552        &state.registry,
553        &state.config,
554        tenant_id,
555    )?;
556
557    // TODO: Check authorization for link deletion
558    // if let Some(link_auth) = &extractor.link_definition.auth {
559    //     check_auth_policy(&headers, &link_auth.delete, &extractor)?;
560    // }
561
562    // Find the existing link first
563    let existing_links = state
564        .link_service
565        .find_by_source(
566            &tenant_id,
567            &extractor.source.id,
568            &extractor.source.entity_type,
569            Some(&extractor.link_definition.link_type),
570            Some(&extractor.target.entity_type),
571        )
572        .await
573        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
574
575    let existing_link = existing_links
576        .into_iter()
577        .find(|link| link.target.id == extractor.target.id)
578        .ok_or(ExtractorError::LinkNotFound)?;
579
580    // Delete the link by its ID
581    state
582        .link_service
583        .delete(&tenant_id, &existing_link.id)
584        .await
585        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
586
587    Ok(StatusCode::NO_CONTENT.into_response())
588}
589
590/// Response for introspection endpoint
591#[derive(Debug, Serialize)]
592pub struct IntrospectionResponse {
593    pub entity_type: String,
594    pub entity_id: Uuid,
595    pub available_routes: Vec<RouteDescription>,
596}
597
598/// Description of an available route
599#[derive(Debug, Serialize)]
600pub struct RouteDescription {
601    pub path: String,
602    pub method: String,
603    pub link_type: String,
604    pub direction: String,
605    pub connected_to: String,
606    pub description: Option<String>,
607}
608
609/// Introspection: List all available link routes for an entity
610///
611/// GET /{entity_type}/{entity_id}/links
612///
613/// Example:
614/// - GET /users/123.../links
615pub async fn list_available_links(
616    State(state): State<AppState>,
617    Path((entity_type_plural, entity_id)): Path<(String, Uuid)>,
618    headers: HeaderMap,
619) -> Result<Json<IntrospectionResponse>, ExtractorError> {
620    let _tenant_id = extract_tenant_id(&headers)?;
621
622    // Convert plural to singular
623    let entity_type = state
624        .config
625        .entities
626        .iter()
627        .find(|e| e.plural == entity_type_plural)
628        .map(|e| e.singular.clone())
629        .unwrap_or_else(|| entity_type_plural.clone());
630
631    // Get all routes for this entity type
632    let routes = state.registry.list_routes_for_entity(&entity_type);
633
634    let available_routes = routes
635        .iter()
636        .map(|r| RouteDescription {
637            path: format!("/{}/{}/{}", entity_type_plural, entity_id, r.route_name),
638            method: "GET".to_string(),
639            link_type: r.link_type.clone(),
640            direction: format!("{:?}", r.direction),
641            connected_to: r.connected_to.clone(),
642            description: r.description.clone(),
643        })
644        .collect();
645
646    Ok(Json(IntrospectionResponse {
647        entity_type,
648        entity_id,
649        available_routes,
650    }))
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use crate::config::EntityConfig;
657    use crate::core::LinkDefinition;
658    use crate::links::service::InMemoryLinkService;
659
660    fn create_test_state() -> AppState {
661        let config = Arc::new(LinksConfig {
662            entities: vec![
663                EntityConfig {
664                    singular: "user".to_string(),
665                    plural: "users".to_string(),
666                    auth: crate::config::EntityAuthConfig::default(),
667                },
668                EntityConfig {
669                    singular: "car".to_string(),
670                    plural: "cars".to_string(),
671                    auth: crate::config::EntityAuthConfig::default(),
672                },
673            ],
674            links: vec![LinkDefinition {
675                link_type: "owner".to_string(),
676                source_type: "user".to_string(),
677                target_type: "car".to_string(),
678                forward_route_name: "cars-owned".to_string(),
679                reverse_route_name: "users-owners".to_string(),
680                description: Some("User owns a car".to_string()),
681                required_fields: None,
682                auth: None,
683            }],
684            validation_rules: None,
685        });
686
687        let registry = Arc::new(LinkRouteRegistry::new(config.clone()));
688        let link_service: Arc<dyn LinkService> = Arc::new(InMemoryLinkService::new());
689
690        AppState {
691            link_service,
692            config,
693            registry,
694            entity_fetchers: Arc::new(HashMap::new()),
695        }
696    }
697
698    #[test]
699    fn test_state_creation() {
700        let state = create_test_state();
701        assert_eq!(state.config.entities.len(), 2);
702        assert_eq!(state.config.links.len(), 1);
703    }
704}