1use 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#[derive(Clone)]
27pub struct AppState {
28 pub link_service: Arc<dyn LinkService>,
29 pub config: Arc<LinksConfig>,
30 pub registry: Arc<LinkRouteRegistry>,
31 pub entity_fetchers: Arc<HashMap<String, Arc<dyn EntityFetcher>>>,
33}
34
35impl AppState {
36 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#[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#[derive(Debug, Serialize)]
79pub struct EnrichedLink {
80 pub id: Uuid,
82
83 pub tenant_id: Uuid,
85
86 pub link_type: String,
88
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub source: Option<serde_json::Value>,
92
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub target: Option<serde_json::Value>,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub metadata: Option<serde_json::Value>,
100
101 pub created_at: DateTime<Utc>,
103
104 pub updated_at: DateTime<Utc>,
106}
107
108#[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#[derive(Debug, Deserialize)]
120pub struct CreateLinkRequest {
121 pub metadata: Option<serde_json::Value>,
122}
123
124#[derive(Debug, Clone, Copy)]
128enum EnrichmentContext {
129 FromSource,
132
133 FromTarget,
136
137 DirectLink,
140}
141
142pub 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 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 let context = match extractor.direction {
207 LinkDirection::Forward => EnrichmentContext::FromSource,
208 LinkDirection::Reverse => EnrichmentContext::FromTarget,
209 };
210
211 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
223async 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 let source_entity = match context {
240 EnrichmentContext::FromSource => None, 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 let target_entity = match context {
249 EnrichmentContext::FromTarget => None, 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
271async 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 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 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
293pub 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 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 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 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
348pub 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 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 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
416pub 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 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
467pub 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 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 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
529pub 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 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 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#[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#[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
609pub 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 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 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}