this/server/router.rs
1//! Router builder utilities for link routes and protocol merging
2
3use crate::core::query::QueryParams;
4use crate::links::handlers::{
5 AppState, create_link, create_linked_entity, delete_link, get_link, get_link_by_route,
6 handle_nested_path_get, list_available_links, list_links, update_link,
7};
8use axum::{Router, extract::Query, routing::get};
9
10/// Combine a REST router and a gRPC router into a single router.
11///
12/// This function safely merges the two routers by taking advantage of the fact
13/// that the gRPC router was built with
14/// [`GrpcExposure::build_router_no_fallback`](crate::server::exposure::grpc::GrpcExposure::build_router_no_fallback),
15/// which does **not** install a fallback handler.
16///
17/// The REST router's fallback (used for deeply nested link paths) is preserved,
18/// ensuring that both REST and gRPC routes work correctly on the same server.
19///
20/// # Arguments
21///
22/// * `rest_router` - The REST router (from [`RestExposure::build_router`](crate::server::exposure::RestExposure::build_router)),
23/// which includes a fallback for nested link paths.
24/// * `grpc_router` - The gRPC router (from [`GrpcExposure::build_router_no_fallback`](crate::server::exposure::grpc::GrpcExposure::build_router_no_fallback)),
25/// which has **no** fallback.
26///
27/// # Example
28///
29/// ```rust,ignore
30/// use this::server::exposure::{RestExposure, grpc::GrpcExposure};
31/// use this::server::router::combine_rest_and_grpc;
32///
33/// let host = Arc::new(builder.build_host()?);
34/// let rest_router = RestExposure::build_router(host.clone(), vec![])?;
35/// let grpc_router = GrpcExposure::build_router_no_fallback(host)?;
36/// let app = combine_rest_and_grpc(rest_router, grpc_router);
37///
38/// axum::serve(listener, app).await?;
39/// ```
40///
41/// # Panics
42///
43/// Panics if `grpc_router` has a fallback installed (e.g., if built with
44/// `GrpcExposure::build_router()` instead of `build_router_no_fallback()`).
45/// Always use `build_router_no_fallback()` for the gRPC side.
46#[cfg(feature = "grpc")]
47pub fn combine_rest_and_grpc(rest_router: Router, grpc_router: Router) -> Router {
48 rest_router.merge(grpc_router)
49}
50
51/// Build link routes from configuration
52///
53/// These routes are generic and work for all entities using semantic route_names:
54/// - GET /links/{link_id} - Get a specific link by ID
55/// - GET /{entity_type}/{entity_id}/{route_name} - List links (e.g., /users/123/cars-owned)
56/// - POST /{entity_type}/{entity_id}/{route_name} - Create new entity + link (entity + metadata in body)
57/// - GET /{source_type}/{source_id}/{route_name}/{target_id} - Get a specific link (e.g., /users/123/cars-owned/456)
58/// - POST /{source_type}/{source_id}/{route_name}/{target_id} - Create link between existing entities
59/// - PUT /{source_type}/{source_id}/{route_name}/{target_id} - Update link metadata
60/// - DELETE /{source_type}/{source_id}/{route_name}/{target_id} - Delete link
61/// - GET /{entity_type}/{entity_id}/links - List available link types
62///
63/// NOTE: Nested routes are supported up to 2 levels automatically:
64/// - GET /{entity_type}/{entity_id}/{route_name} - List linked entities
65/// - GET /{entity_type}/{entity_id}/{route_name}/{target_id} - Get specific link
66///
67/// For deeper nesting (3+ levels), see: docs/guides/CUSTOM_NESTED_ROUTES.md
68///
69/// The route_name (e.g., "cars-owned", "cars-driven") is resolved to the appropriate
70/// link_type (e.g., "owner", "driver") automatically by the LinkRouteRegistry.
71pub fn build_link_routes(state: AppState) -> Router {
72 use axum::extract::{Path as AxumPath, Request, State as AxumState};
73 use axum::response::IntoResponse;
74 use uuid::Uuid;
75
76 // Handler intelligent qui route vers list_links OU handle_nested_path_get selon la profondeur
77 let smart_handler = |AxumState(state): AxumState<AppState>,
78 AxumPath((entity_type_plural, entity_id, route_name)): AxumPath<(
79 String,
80 Uuid,
81 String,
82 )>,
83 Query(params): Query<QueryParams>,
84 req: Request| async move {
85 let path = req.uri().path();
86 let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
87
88 // Si plus de 3 segments, c'est une route imbriquée à 3+ niveaux
89 if segments.len() >= 5 {
90 // Utiliser le handler générique pour chemins profonds (with pagination)
91 handle_nested_path_get(AxumState(state), AxumPath(path.to_string()), Query(params))
92 .await
93 .map(|r| r.into_response())
94 } else {
95 // Route classique à 2 niveaux - with pagination
96 list_links(
97 AxumState(state),
98 AxumPath((entity_type_plural, entity_id, route_name)),
99 Query(params),
100 )
101 .await
102 .map(|r| r.into_response())
103 }
104 };
105
106 // Handler fallback pour les autres cas (with pagination)
107 let fallback_handler = |AxumState(state): AxumState<AppState>,
108 Query(params): Query<QueryParams>,
109 req: Request| async move {
110 let path = req.uri().path().to_string();
111 handle_nested_path_get(AxumState(state), AxumPath(path), Query(params))
112 .await
113 .map(|r| r.into_response())
114 };
115
116 Router::new()
117 .route("/links/{link_id}", get(get_link))
118 .route(
119 "/{entity_type}/{entity_id}/{route_name}",
120 get(smart_handler).post(create_linked_entity),
121 )
122 .route(
123 "/{source_type}/{source_id}/{route_name}/{target_id}",
124 get(get_link_by_route)
125 .post(create_link)
126 .put(update_link)
127 .delete(delete_link),
128 )
129 .route(
130 "/{entity_type}/{entity_id}/links",
131 get(list_available_links),
132 )
133 .fallback(fallback_handler)
134 .with_state(state)
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::config::LinksConfig;
141 use crate::core::events::EventBus;
142 use crate::links::handlers::AppState;
143 use crate::links::registry::LinkRouteRegistry;
144 use crate::storage::InMemoryLinkService;
145 use std::collections::HashMap;
146 use std::sync::Arc;
147
148 /// Build a minimal AppState for testing
149 fn test_app_state() -> AppState {
150 let config = Arc::new(LinksConfig::default_config());
151 let registry = Arc::new(LinkRouteRegistry::new(config.clone()));
152 AppState {
153 link_service: Arc::new(InMemoryLinkService::new()),
154 config,
155 registry,
156 entity_fetchers: Arc::new(HashMap::new()),
157 entity_creators: Arc::new(HashMap::new()),
158 event_bus: None,
159 }
160 }
161
162 #[test]
163 fn test_build_link_routes_produces_router() {
164 let state = test_app_state();
165 let router = build_link_routes(state);
166 // Should not panic; router is valid
167 let _ = router;
168 }
169
170 #[test]
171 fn test_build_link_routes_with_event_bus() {
172 let config = Arc::new(LinksConfig::default_config());
173 let registry = Arc::new(LinkRouteRegistry::new(config.clone()));
174 let state = AppState {
175 link_service: Arc::new(InMemoryLinkService::new()),
176 config,
177 registry,
178 entity_fetchers: Arc::new(HashMap::new()),
179 entity_creators: Arc::new(HashMap::new()),
180 event_bus: Some(Arc::new(EventBus::new(16))),
181 };
182 let router = build_link_routes(state);
183 let _ = router;
184 }
185
186 #[test]
187 fn test_build_link_routes_empty_config() {
188 let config = Arc::new(LinksConfig {
189 entities: vec![],
190 links: vec![],
191 validation_rules: None,
192 events: None,
193 sinks: None,
194 });
195 let registry = Arc::new(LinkRouteRegistry::new(config.clone()));
196 let state = AppState {
197 link_service: Arc::new(InMemoryLinkService::new()),
198 config,
199 registry,
200 entity_fetchers: Arc::new(HashMap::new()),
201 entity_creators: Arc::new(HashMap::new()),
202 event_bus: None,
203 };
204 let router = build_link_routes(state);
205 let _ = router;
206 }
207
208 #[cfg(feature = "grpc")]
209 mod grpc_tests {
210 use super::super::combine_rest_and_grpc;
211 use axum::Router;
212
213 #[test]
214 fn test_combine_rest_and_grpc_merges_routers() {
215 let rest = Router::new();
216 let grpc = Router::new();
217 let combined = combine_rest_and_grpc(rest, grpc);
218 let _ = combined;
219 }
220 }
221}