Skip to main content

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}