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::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::{DirectLinkExtractor, ExtractorError, LinkExtractor};
20use crate::core::{link::LinkEntity, EntityCreator, EntityFetcher, LinkDefinition, LinkService};
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
323    let existing_links = state
324        .link_service
325        .find_by_source(
326            &extractor.source_id,
327            Some(&extractor.link_definition.link_type),
328            Some(&extractor.target_type),
329        )
330        .await
331        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
332
333    let link = existing_links
334        .into_iter()
335        .find(|link| link.target_id == extractor.target_id)
336        .ok_or(ExtractorError::LinkNotFound)?;
337
338    // Enrich with both source and target entities
339    let enriched_links = enrich_links_with_entities(
340        &state,
341        vec![link],
342        EnrichmentContext::DirectLink,
343        &extractor.link_definition,
344    )
345    .await?;
346
347    let enriched_link = enriched_links
348        .into_iter()
349        .next()
350        .ok_or(ExtractorError::LinkNotFound)?;
351
352    Ok(Json(enriched_link).into_response())
353}
354
355/// Create a link between two existing entities
356///
357/// POST /{source_type}/{source_id}/{route_name}/{target_id}
358/// Body: { "metadata": {...} }
359pub async fn create_link(
360    State(state): State<AppState>,
361    Path((source_type_plural, source_id, route_name, target_id)): Path<(
362        String,
363        Uuid,
364        String,
365        Uuid,
366    )>,
367    Json(payload): Json<CreateLinkRequest>,
368) -> Result<Response, ExtractorError> {
369    let extractor = DirectLinkExtractor::from_path(
370        (source_type_plural, source_id, route_name, target_id),
371        &state.registry,
372        &state.config,
373    )?;
374
375    // Create the link between existing entities
376    let link = LinkEntity::new(
377        extractor.link_definition.link_type,
378        extractor.source_id,
379        extractor.target_id,
380        payload.metadata,
381    );
382
383    let created_link = state
384        .link_service
385        .create(link)
386        .await
387        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
388
389    Ok((StatusCode::CREATED, Json(created_link)).into_response())
390}
391
392/// Create a new entity and link it to the source
393///
394/// POST /{source_type}/{source_id}/{route_name}
395/// Body: { "entity": {...entity fields...}, "metadata": {...link metadata...} }
396pub async fn create_linked_entity(
397    State(state): State<AppState>,
398    Path((source_type_plural, source_id, route_name)): Path<(String, Uuid, String)>,
399    Json(payload): Json<CreateLinkedEntityRequest>,
400) -> Result<Response, ExtractorError> {
401    let extractor = LinkExtractor::from_path_and_registry(
402        (source_type_plural.clone(), source_id, route_name.clone()),
403        &state.registry,
404        &state.config,
405    )?;
406
407    // Determine source and target based on direction
408    let (source_entity_id, target_entity_type) = match extractor.direction {
409        LinkDirection::Forward => {
410            // Forward: source is the entity in the URL, target is the new entity
411            (extractor.entity_id, &extractor.link_definition.target_type)
412        }
413        LinkDirection::Reverse => {
414            // Reverse: target is the entity in the URL, source is the new entity
415            (extractor.entity_id, &extractor.link_definition.source_type)
416        }
417    };
418
419    // Get the entity creator for the target type
420    let entity_creator = state
421        .entity_creators
422        .get(target_entity_type)
423        .ok_or_else(|| {
424            ExtractorError::JsonError(format!(
425                "No entity creator registered for type: {}",
426                target_entity_type
427            ))
428        })?;
429
430    // Create the new entity
431    let created_entity = entity_creator
432        .create_from_json(payload.entity)
433        .await
434        .map_err(|e| ExtractorError::JsonError(format!("Failed to create entity: {}", e)))?;
435
436    // Extract the ID from the created entity
437    let target_entity_id = created_entity["id"].as_str().ok_or_else(|| {
438        ExtractorError::JsonError("Created entity missing 'id' field".to_string())
439    })?;
440    let target_entity_id = Uuid::parse_str(target_entity_id)
441        .map_err(|e| ExtractorError::JsonError(format!("Invalid UUID in created entity: {}", e)))?;
442
443    // Create the link based on direction
444    let link = match extractor.direction {
445        LinkDirection::Forward => {
446            // Forward: source -> target (new entity)
447            LinkEntity::new(
448                extractor.link_definition.link_type,
449                source_entity_id,
450                target_entity_id,
451                payload.metadata,
452            )
453        }
454        LinkDirection::Reverse => {
455            // Reverse: source (new entity) -> target
456            LinkEntity::new(
457                extractor.link_definition.link_type,
458                target_entity_id,
459                source_entity_id,
460                payload.metadata,
461            )
462        }
463    };
464
465    let created_link = state
466        .link_service
467        .create(link)
468        .await
469        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
470
471    // Return both the created entity and the link
472    let response = serde_json::json!({
473        "entity": created_entity,
474        "link": created_link,
475    });
476
477    Ok((StatusCode::CREATED, Json(response)).into_response())
478}
479
480/// Update a link's metadata using route name
481///
482/// PUT/PATCH /{source_type}/{source_id}/{route_name}/{target_id}
483pub async fn update_link(
484    State(state): State<AppState>,
485    Path((source_type_plural, source_id, route_name, target_id)): Path<(
486        String,
487        Uuid,
488        String,
489        Uuid,
490    )>,
491    Json(payload): Json<CreateLinkRequest>,
492) -> Result<Response, ExtractorError> {
493    let extractor = DirectLinkExtractor::from_path(
494        (source_type_plural, source_id, route_name, target_id),
495        &state.registry,
496        &state.config,
497    )?;
498
499    // Find the existing link
500    let existing_links = state
501        .link_service
502        .find_by_source(
503            &extractor.source_id,
504            Some(&extractor.link_definition.link_type),
505            Some(&extractor.target_type),
506        )
507        .await
508        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
509
510    let mut existing_link = existing_links
511        .into_iter()
512        .find(|link| link.target_id == extractor.target_id)
513        .ok_or_else(|| ExtractorError::RouteNotFound("Link not found".to_string()))?;
514
515    // Update metadata
516    existing_link.metadata = payload.metadata;
517    existing_link.touch();
518
519    // Save the updated link
520    let link_id = existing_link.id;
521    let updated_link = state
522        .link_service
523        .update(&link_id, existing_link)
524        .await
525        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
526
527    Ok(Json(updated_link).into_response())
528}
529
530/// Delete a link using route name
531///
532/// DELETE /{source_type}/{source_id}/{route_name}/{target_id}
533pub async fn delete_link(
534    State(state): State<AppState>,
535    Path((source_type_plural, source_id, route_name, target_id)): Path<(
536        String,
537        Uuid,
538        String,
539        Uuid,
540    )>,
541) -> Result<Response, ExtractorError> {
542    let extractor = DirectLinkExtractor::from_path(
543        (source_type_plural, source_id, route_name, target_id),
544        &state.registry,
545        &state.config,
546    )?;
547
548    // Find the existing link first
549    let existing_links = state
550        .link_service
551        .find_by_source(
552            &extractor.source_id,
553            Some(&extractor.link_definition.link_type),
554            Some(&extractor.target_type),
555        )
556        .await
557        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
558
559    let existing_link = existing_links
560        .into_iter()
561        .find(|link| link.target_id == extractor.target_id)
562        .ok_or(ExtractorError::LinkNotFound)?;
563
564    // Delete the link by its ID
565    state
566        .link_service
567        .delete(&existing_link.id)
568        .await
569        .map_err(|e| ExtractorError::JsonError(e.to_string()))?;
570
571    Ok(StatusCode::NO_CONTENT.into_response())
572}
573
574/// Response for introspection endpoint
575#[derive(Debug, Serialize)]
576pub struct IntrospectionResponse {
577    pub entity_type: String,
578    pub entity_id: Uuid,
579    pub available_routes: Vec<RouteDescription>,
580}
581
582/// Description of an available route
583#[derive(Debug, Serialize)]
584pub struct RouteDescription {
585    pub path: String,
586    pub method: String,
587    pub link_type: String,
588    pub direction: String,
589    pub connected_to: String,
590    pub description: Option<String>,
591}
592
593/// Introspection: List all available link routes for an entity
594///
595/// GET /{entity_type}/{entity_id}/links
596pub async fn list_available_links(
597    State(state): State<AppState>,
598    Path((entity_type_plural, entity_id)): Path<(String, Uuid)>,
599) -> Result<Json<IntrospectionResponse>, ExtractorError> {
600    // Convert plural to singular
601    let entity_type = state
602        .config
603        .entities
604        .iter()
605        .find(|e| e.plural == entity_type_plural)
606        .map(|e| e.singular.clone())
607        .unwrap_or_else(|| entity_type_plural.clone());
608
609    // Get all routes for this entity type
610    let routes = state.registry.list_routes_for_entity(&entity_type);
611
612    let available_routes = routes
613        .iter()
614        .map(|r| RouteDescription {
615            path: format!("/{}/{}/{}", entity_type_plural, entity_id, r.route_name),
616            method: "GET".to_string(),
617            link_type: r.link_type.clone(),
618            direction: format!("{:?}", r.direction),
619            connected_to: r.connected_to.clone(),
620            description: r.description.clone(),
621        })
622        .collect();
623
624    Ok(Json(IntrospectionResponse {
625        entity_type,
626        entity_id,
627        available_routes,
628    }))
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use crate::config::EntityConfig;
635    use crate::core::LinkDefinition;
636    use crate::storage::InMemoryLinkService;
637
638    fn create_test_state() -> AppState {
639        let config = Arc::new(LinksConfig {
640            entities: vec![
641                EntityConfig {
642                    singular: "user".to_string(),
643                    plural: "users".to_string(),
644                    auth: crate::config::EntityAuthConfig::default(),
645                },
646                EntityConfig {
647                    singular: "car".to_string(),
648                    plural: "cars".to_string(),
649                    auth: crate::config::EntityAuthConfig::default(),
650                },
651            ],
652            links: vec![LinkDefinition {
653                link_type: "owner".to_string(),
654                source_type: "user".to_string(),
655                target_type: "car".to_string(),
656                forward_route_name: "cars-owned".to_string(),
657                reverse_route_name: "users-owners".to_string(),
658                description: Some("User owns a car".to_string()),
659                required_fields: None,
660                auth: None,
661            }],
662            validation_rules: None,
663        });
664
665        let registry = Arc::new(LinkRouteRegistry::new(config.clone()));
666        let link_service: Arc<dyn LinkService> = Arc::new(InMemoryLinkService::new());
667
668        AppState {
669            link_service,
670            config,
671            registry,
672            entity_fetchers: Arc::new(HashMap::new()),
673            entity_creators: Arc::new(HashMap::new()),
674        }
675    }
676
677    #[test]
678    fn test_state_creation() {
679        let state = create_test_state();
680        assert_eq!(state.config.entities.len(), 2);
681        assert_eq!(state.config.links.len(), 1);
682    }
683}