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