rustapi_core/app/routing.rs
1use super::helpers::{add_path_params_to_operation, normalize_prefix_for_openapi};
2use super::types::RustApi;
3use crate::response::IntoResponse;
4use crate::router::{MethodRouter, Router};
5use std::collections::BTreeMap;
6
7impl RustApi {
8 pub(super) fn mount_auto_routes_grouped(mut self) -> Self {
9 let routes = crate::auto_route::collect_auto_routes();
10
11 if routes.is_empty() {
12 // This is a common source of confusion with linkme-based auto registration.
13 // We emit a clear warning so users know their annotated handlers were not linked.
14 tracing::warn!(
15 target: "rustapi::auto",
16 count = 0,
17 "RustApi::auto() collected 0 routes. \
18 This usually means either:\n\
19 - No handlers were annotated with #[rustapi_rs::get], #[post], etc.\n\
20 - The binary/test was not linked with the annotated modules (common in some test setups).\n\
21 - You are building a library (cdylib/rlib) where linkme distributed slices may not be populated.\n\n\
22 You can still register routes manually with .route() or check with rustapi_rs::auto_route_count()."
23 );
24 } else {
25 #[cfg(feature = "tracing")]
26 tracing::debug!(
27 target: "rustapi::auto",
28 count = routes.len(),
29 "Auto route collection found handlers"
30 );
31 }
32
33 // Use BTreeMap for deterministic route registration order
34 let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();
35
36 for route in routes {
37 let crate::handler::Route {
38 path: route_path,
39 method,
40 handler,
41 operation,
42 component_registrar,
43 ..
44 } = route;
45
46 let method_enum = match method {
47 "GET" => http::Method::GET,
48 "POST" => http::Method::POST,
49 "PUT" => http::Method::PUT,
50 "DELETE" => http::Method::DELETE,
51 "PATCH" => http::Method::PATCH,
52 _ => http::Method::GET,
53 };
54
55 let path = if route_path.starts_with('/') {
56 route_path.to_string()
57 } else {
58 format!("/{}", route_path)
59 };
60
61 let entry = by_path.entry(path).or_default();
62 entry.insert_boxed_with_operation(method_enum, handler, operation, component_registrar);
63 }
64
65 #[cfg(feature = "tracing")]
66 let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
67 #[cfg(feature = "tracing")]
68 let path_count = by_path.len();
69
70 for (path, method_router) in by_path {
71 self = self.route(&path, method_router);
72 }
73
74 crate::trace_info!(
75 paths = path_count,
76 routes = route_count,
77 "Auto-registered routes"
78 );
79
80 // Apply any auto-registered schemas.
81 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
82
83 self
84 }
85
86 /// Add a route
87 ///
88 /// # Example
89 ///
90 /// ```rust,ignore
91 /// RustApi::new()
92 /// .route("/", get(index))
93 /// .route("/users", get(list_users).post(create_user))
94 /// .route("/users/{id}", get(get_user).delete(delete_user))
95 /// ```
96 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
97 for register_components in &method_router.component_registrars {
98 register_components(&mut self.openapi_spec);
99 }
100
101 // Register operations in OpenAPI spec
102 for (method, op) in &method_router.operations {
103 let mut op = op.clone();
104 add_path_params_to_operation(path, &mut op, &BTreeMap::new());
105 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
106 }
107
108 self.router = self.router.route(path, method_router);
109 self
110 }
111
112 /// Add a typed route
113 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
114 self.route(P::PATH, method_router)
115 }
116
117 /// Mount a handler (convenience method)
118 ///
119 /// Alias for `.route(path, method_router)` for a single handler.
120 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
121 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
122 self.route(path, method_router)
123 }
124
125 /// Mount a route created with `#[rustapi::get]`, `#[rustapi::post]`, etc.
126 ///
127 /// # Example
128 ///
129 /// ```rust,ignore
130 /// use rustapi_rs::prelude::*;
131 ///
132 /// #[rustapi::get("/users")]
133 /// async fn list_users() -> Json<Vec<User>> {
134 /// Json(vec![])
135 /// }
136 ///
137 /// RustApi::new()
138 /// .mount_route(route!(list_users))
139 /// .run("127.0.0.1:8080")
140 /// .await
141 /// ```
142 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
143 let method_enum = match route.method {
144 "GET" => http::Method::GET,
145 "POST" => http::Method::POST,
146 "PUT" => http::Method::PUT,
147 "DELETE" => http::Method::DELETE,
148 "PATCH" => http::Method::PATCH,
149 _ => http::Method::GET,
150 };
151
152 (route.component_registrar)(&mut self.openapi_spec);
153
154 // Register operation in OpenAPI spec
155 let mut op = route.operation;
156 add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
157 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
158
159 self.route_with_method(route.path, method_enum, route.handler)
160 }
161
162 /// Helper to mount a single method handler
163 fn route_with_method(
164 self,
165 path: &str,
166 method: http::Method,
167 handler: crate::handler::BoxedHandler,
168 ) -> Self {
169 use crate::router::MethodRouter;
170 // use http::Method; // Removed
171
172 // This is simplified. In a real implementation we'd merge with existing router at this path
173 // For now we assume one handler per path or we simply allow overwriting for this MVP step
174 // (matchit router doesn't allow easy merging/updating existing entries without rebuilding)
175 //
176 // TODO: Enhance Router to support method merging
177
178 let path = if !path.starts_with('/') {
179 format!("/{}", path)
180 } else {
181 path.to_string()
182 };
183
184 // Check if we already have this path?
185 // For MVP, valid assumption: user calls .route() or .mount() once per path-method-combo
186 // But we need to handle multiple methods on same path.
187 // Our Router wrapper currently just inserts.
188
189 // Since we can't easily query matchit, we'll just insert.
190 // Limitations: strictly sequential mounting for now.
191
192 let mut handlers = std::collections::HashMap::new();
193 handlers.insert(method, handler);
194
195 let method_router = MethodRouter::from_boxed(handlers);
196 self.route(&path, method_router)
197 }
198
199 /// Nest a router under a prefix
200 ///
201 /// All routes from the nested router will be registered with the prefix
202 /// prepended to their paths. OpenAPI operations from the nested router
203 /// are also propagated to the parent's OpenAPI spec with prefixed paths.
204 ///
205 /// # Example
206 ///
207 /// ```rust,ignore
208 /// let api_v1 = Router::new()
209 /// .route("/users", get(list_users));
210 ///
211 /// RustApi::new()
212 /// .nest("/api/v1", api_v1)
213 /// ```
214 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
215 // Normalize the prefix for OpenAPI paths
216 let normalized_prefix = normalize_prefix_for_openapi(prefix);
217
218 // Propagate OpenAPI operations from nested router with prefixed paths
219 // We need to do this before calling router.nest() because it consumes the router
220 for (matchit_path, method_router) in router.method_routers() {
221 for register_components in &method_router.component_registrars {
222 register_components(&mut self.openapi_spec);
223 }
224
225 // Get the display path from registered_routes (has {param} format)
226 let display_path = router
227 .registered_routes()
228 .get(matchit_path)
229 .map(|info| info.path.clone())
230 .unwrap_or_else(|| matchit_path.clone());
231
232 // Build the prefixed display path for OpenAPI
233 let prefixed_path = if display_path == "/" {
234 normalized_prefix.clone()
235 } else {
236 format!("{}{}", normalized_prefix, display_path)
237 };
238
239 // Register each operation in the OpenAPI spec
240 for (method, op) in &method_router.operations {
241 let mut op = op.clone();
242 add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
243 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
244 }
245 }
246
247 // Delegate to Router::nest for actual route registration
248 self.router = self.router.nest(prefix, router);
249 self
250 }
251
252 /// Serve static files from a directory
253 ///
254 /// Maps a URL path prefix to a filesystem directory. Requests to paths under
255 /// the prefix will serve files from the corresponding location in the directory.
256 ///
257 /// # Arguments
258 ///
259 /// * `prefix` - URL path prefix (e.g., "/static", "/assets")
260 /// * `root` - Filesystem directory path
261 ///
262 /// # Features
263 ///
264 /// - Automatic MIME type detection
265 /// - ETag and Last-Modified headers for caching
266 /// - Index file serving for directories
267 /// - Path traversal prevention
268 ///
269 /// # Example
270 ///
271 /// ```rust,ignore
272 /// use rustapi_rs::prelude::*;
273 ///
274 /// RustApi::new()
275 /// .serve_static("/assets", "./public")
276 /// .serve_static("/uploads", "./uploads")
277 /// .run("127.0.0.1:8080")
278 /// .await
279 /// ```
280 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
281 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
282 }
283
284 /// Serve static files with custom configuration
285 ///
286 /// # Example
287 ///
288 /// ```rust,ignore
289 /// use rustapi_core::static_files::StaticFileConfig;
290 ///
291 /// let config = StaticFileConfig::new("./public", "/assets")
292 /// .max_age(86400) // Cache for 1 day
293 /// .fallback("index.html"); // SPA fallback
294 ///
295 /// RustApi::new()
296 /// .serve_static_with_config(config)
297 /// .run("127.0.0.1:8080")
298 /// .await
299 /// ```
300 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
301 use crate::router::MethodRouter;
302 use std::collections::HashMap;
303
304 let prefix = config.prefix.clone();
305 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
306
307 // Create the static file handler
308 let handler: crate::handler::BoxedHandler =
309 std::sync::Arc::new(move |req: crate::Request| {
310 let config = config.clone();
311 let path = req.uri().path().to_string();
312
313 Box::pin(async move {
314 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
315
316 match crate::static_files::StaticFile::serve(relative_path, &config).await {
317 Ok(response) => response,
318 Err(err) => err.into_response(),
319 }
320 })
321 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
322 });
323
324 let mut handlers = HashMap::new();
325 handlers.insert(http::Method::GET, handler);
326 let method_router = MethodRouter::from_boxed(handlers);
327
328 self.route(&catch_all_path, method_router)
329 }
330
331 /// Enable response compression
332 ///
333 /// Adds gzip/deflate compression for response bodies. The compression
334 /// is based on the client's Accept-Encoding header.
335 ///
336 /// # Example
337 ///
338 /// ```rust,ignore
339 /// use rustapi_rs::prelude::*;
340 ///
341 /// RustApi::new()
342 /// .compression()
343 /// .route("/", get(handler))
344 /// .run("127.0.0.1:8080")
345 /// .await
346 /// ```
347 #[cfg(feature = "compression")]
348 pub fn compression(self) -> Self {
349 self.layer(crate::middleware::CompressionLayer::new())
350 }
351
352 /// Enable response compression with custom configuration
353 ///
354 /// # Example
355 ///
356 /// ```rust,ignore
357 /// use rustapi_core::middleware::CompressionConfig;
358 ///
359 /// RustApi::new()
360 /// .compression_with_config(
361 /// CompressionConfig::new()
362 /// .min_size(512)
363 /// .level(9)
364 /// )
365 /// .route("/", get(handler))
366 /// ```
367 #[cfg(feature = "compression")]
368 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
369 self.layer(crate::middleware::CompressionLayer::with_config(config))
370 }
371}