1use 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#[derive(Clone)]
25pub struct AppState {
26 pub link_service: Arc<dyn LinkService>,
27 pub config: Arc<LinksConfig>,
28 pub registry: Arc<LinkRouteRegistry>,
29 pub entity_fetchers: Arc<HashMap<String, Arc<dyn EntityFetcher>>>,
31 pub entity_creators: Arc<HashMap<String, Arc<dyn EntityCreator>>>,
33}
34
35impl AppState {
36 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#[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#[derive(Debug, Serialize)]
64pub struct EnrichedLink {
65 pub id: Uuid,
67
68 #[serde(rename = "type")]
70 pub entity_type: String,
71
72 pub link_type: String,
74
75 pub source_id: Uuid,
77
78 pub target_id: Uuid,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub source: Option<serde_json::Value>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub target: Option<serde_json::Value>,
88
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub metadata: Option<serde_json::Value>,
92
93 pub created_at: DateTime<Utc>,
95
96 pub updated_at: DateTime<Utc>,
98
99 pub status: String,
101}
102
103#[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#[derive(Debug, Deserialize)]
115pub struct CreateLinkRequest {
116 pub metadata: Option<serde_json::Value>,
117}
118
119#[derive(Debug, Deserialize)]
121pub struct CreateLinkedEntityRequest {
122 pub entity: serde_json::Value,
123 pub metadata: Option<serde_json::Value>,
124}
125
126#[derive(Debug, Clone, Copy)]
128enum EnrichmentContext {
129 FromSource,
131 FromTarget,
133 DirectLink,
135}
136
137pub 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 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 let context = match extractor.direction {
174 LinkDirection::Forward => EnrichmentContext::FromSource,
175 LinkDirection::Reverse => EnrichmentContext::FromTarget,
176 };
177
178 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
191async 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 let source_entity = match context {
203 EnrichmentContext::FromSource => None,
204 EnrichmentContext::FromTarget | EnrichmentContext::DirectLink => {
205 fetch_entity_by_type(state, &link_definition.source_type, &link.source_id)
207 .await
208 .ok()
209 }
210 };
211
212 let target_entity = match context {
214 EnrichmentContext::FromTarget => None,
215 EnrichmentContext::FromSource | EnrichmentContext::DirectLink => {
216 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
241async 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
260pub 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 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 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
304pub 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 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 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
355pub 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 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
392pub 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 let (source_entity_id, target_entity_type) = match extractor.direction {
409 LinkDirection::Forward => {
410 (extractor.entity_id, &extractor.link_definition.target_type)
412 }
413 LinkDirection::Reverse => {
414 (extractor.entity_id, &extractor.link_definition.source_type)
416 }
417 };
418
419 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 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 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 let link = match extractor.direction {
445 LinkDirection::Forward => {
446 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 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 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
480pub 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 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 existing_link.metadata = payload.metadata;
517 existing_link.touch();
518
519 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
530pub 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 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 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#[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#[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
593pub 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 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 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}