rustapi_core/app.rs
1//! RustApi application builder
2
3use crate::error::Result;
4use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT};
5use crate::response::IntoResponse;
6use crate::router::{MethodRouter, Router};
7use crate::server::Server;
8use std::collections::HashMap;
9use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
10
11/// Main application builder for RustAPI
12///
13/// # Example
14///
15/// ```rust,ignore
16/// use rustapi_rs::prelude::*;
17///
18/// #[tokio::main]
19/// async fn main() -> Result<()> {
20/// RustApi::new()
21/// .state(AppState::new())
22/// .route("/", get(hello))
23/// .route("/users/{id}", get(get_user))
24/// .run("127.0.0.1:8080")
25/// .await
26/// }
27/// ```
28pub struct RustApi {
29 router: Router,
30 openapi_spec: rustapi_openapi::OpenApiSpec,
31 layers: LayerStack,
32 body_limit: Option<usize>,
33}
34
35impl RustApi {
36 /// Create a new RustAPI application
37 pub fn new() -> Self {
38 // Initialize tracing if not already done
39 let _ = tracing_subscriber::registry()
40 .with(
41 EnvFilter::try_from_default_env()
42 .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
43 )
44 .with(tracing_subscriber::fmt::layer())
45 .try_init();
46
47 Self {
48 router: Router::new(),
49 openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
50 .register::<rustapi_openapi::ErrorSchema>()
51 .register::<rustapi_openapi::ErrorBodySchema>()
52 .register::<rustapi_openapi::ValidationErrorSchema>()
53 .register::<rustapi_openapi::ValidationErrorBodySchema>()
54 .register::<rustapi_openapi::FieldErrorSchema>(),
55 layers: LayerStack::new(),
56 body_limit: Some(DEFAULT_BODY_LIMIT), // Default 1MB limit
57 }
58 }
59
60 /// Create a zero-config RustAPI application.
61 ///
62 /// All routes decorated with `#[rustapi::get]`, `#[rustapi::post]`, etc.
63 /// are automatically registered. Swagger UI is enabled at `/docs` by default.
64 ///
65 /// # Example
66 ///
67 /// ```rust,ignore
68 /// use rustapi_rs::prelude::*;
69 ///
70 /// #[rustapi::get("/users")]
71 /// async fn list_users() -> Json<Vec<User>> {
72 /// Json(vec![])
73 /// }
74 ///
75 /// #[rustapi::main]
76 /// async fn main() -> Result<()> {
77 /// // Zero config - routes are auto-registered!
78 /// RustApi::auto()
79 /// .run("0.0.0.0:8080")
80 /// .await
81 /// }
82 /// ```
83 #[cfg(feature = "swagger-ui")]
84 pub fn auto() -> Self {
85 // Build app with grouped auto-routes and auto-schemas, then enable docs.
86 Self::new().mount_auto_routes_grouped().docs("/docs")
87 }
88
89 /// Create a zero-config RustAPI application (without swagger-ui feature).
90 ///
91 /// All routes decorated with `#[rustapi::get]`, `#[rustapi::post]`, etc.
92 /// are automatically registered.
93 #[cfg(not(feature = "swagger-ui"))]
94 pub fn auto() -> Self {
95 Self::new().mount_auto_routes_grouped()
96 }
97
98 /// Create a configurable RustAPI application with auto-routes.
99 ///
100 /// Provides builder methods for customization while still
101 /// auto-registering all decorated routes.
102 ///
103 /// # Example
104 ///
105 /// ```rust,ignore
106 /// use rustapi_rs::prelude::*;
107 ///
108 /// RustApi::config()
109 /// .docs_path("/api-docs")
110 /// .body_limit(5 * 1024 * 1024) // 5MB
111 /// .openapi_info("My API", "2.0.0", Some("API Description"))
112 /// .run("0.0.0.0:8080")
113 /// .await?;
114 /// ```
115 pub fn config() -> RustApiConfig {
116 RustApiConfig::new()
117 }
118
119 /// Set the global body size limit for request bodies
120 ///
121 /// This protects against denial-of-service attacks via large payloads.
122 /// The default limit is 1MB (1024 * 1024 bytes).
123 ///
124 /// # Arguments
125 ///
126 /// * `limit` - Maximum body size in bytes
127 ///
128 /// # Example
129 ///
130 /// ```rust,ignore
131 /// use rustapi_rs::prelude::*;
132 ///
133 /// RustApi::new()
134 /// .body_limit(5 * 1024 * 1024) // 5MB limit
135 /// .route("/upload", post(upload_handler))
136 /// .run("127.0.0.1:8080")
137 /// .await
138 /// ```
139 pub fn body_limit(mut self, limit: usize) -> Self {
140 self.body_limit = Some(limit);
141 self
142 }
143
144 /// Disable the body size limit
145 ///
146 /// Warning: This removes protection against large payload attacks.
147 /// Only use this if you have other mechanisms to limit request sizes.
148 ///
149 /// # Example
150 ///
151 /// ```rust,ignore
152 /// RustApi::new()
153 /// .no_body_limit() // Disable body size limit
154 /// .route("/upload", post(upload_handler))
155 /// ```
156 pub fn no_body_limit(mut self) -> Self {
157 self.body_limit = None;
158 self
159 }
160
161 /// Add a middleware layer to the application
162 ///
163 /// Layers are executed in the order they are added (outermost first).
164 /// The first layer added will be the first to process the request and
165 /// the last to process the response.
166 ///
167 /// # Example
168 ///
169 /// ```rust,ignore
170 /// use rustapi_rs::prelude::*;
171 /// use rustapi_core::middleware::{RequestIdLayer, TracingLayer};
172 ///
173 /// RustApi::new()
174 /// .layer(RequestIdLayer::new()) // First to process request
175 /// .layer(TracingLayer::new()) // Second to process request
176 /// .route("/", get(handler))
177 /// .run("127.0.0.1:8080")
178 /// .await
179 /// ```
180 pub fn layer<L>(mut self, layer: L) -> Self
181 where
182 L: MiddlewareLayer,
183 {
184 self.layers.push(Box::new(layer));
185 self
186 }
187
188 /// Add application state
189 ///
190 /// State is shared across all handlers and can be extracted using `State<T>`.
191 ///
192 /// # Example
193 ///
194 /// ```rust,ignore
195 /// #[derive(Clone)]
196 /// struct AppState {
197 /// db: DbPool,
198 /// }
199 ///
200 /// RustApi::new()
201 /// .state(AppState::new())
202 /// ```
203 pub fn state<S>(self, _state: S) -> Self
204 where
205 S: Clone + Send + Sync + 'static,
206 {
207 // Store state in the router's shared Extensions so `State<T>` extractor can retrieve it.
208 let state = _state;
209 let mut app = self;
210 app.router = app.router.state(state);
211 app
212 }
213
214 /// Register an OpenAPI schema
215 ///
216 /// # Example
217 ///
218 /// ```rust,ignore
219 /// #[derive(Schema)]
220 /// struct User { ... }
221 ///
222 /// RustApi::new()
223 /// .register_schema::<User>()
224 /// ```
225 pub fn register_schema<T: for<'a> rustapi_openapi::Schema<'a>>(mut self) -> Self {
226 self.openapi_spec = self.openapi_spec.register::<T>();
227 self
228 }
229
230 /// Configure OpenAPI info (title, version, description)
231 pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
232 // NOTE: Do not reset the spec here; doing so would drop collected paths/schemas.
233 // This is especially important for `RustApi::auto()` and `RustApi::config()`.
234 self.openapi_spec.info.title = title.to_string();
235 self.openapi_spec.info.version = version.to_string();
236 self.openapi_spec.info.description = description.map(|d| d.to_string());
237 self
238 }
239
240 /// Get the current OpenAPI spec (for advanced usage/testing).
241 pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
242 &self.openapi_spec
243 }
244
245 fn mount_auto_routes_grouped(mut self) -> Self {
246 let routes = crate::auto_route::collect_auto_routes();
247 let mut by_path: HashMap<String, MethodRouter> = HashMap::new();
248
249 for route in routes {
250 let method_enum = match route.method {
251 "GET" => http::Method::GET,
252 "POST" => http::Method::POST,
253 "PUT" => http::Method::PUT,
254 "DELETE" => http::Method::DELETE,
255 "PATCH" => http::Method::PATCH,
256 _ => http::Method::GET,
257 };
258
259 let path = if route.path.starts_with('/') {
260 route.path.to_string()
261 } else {
262 format!("/{}", route.path)
263 };
264
265 let entry = by_path.entry(path).or_default();
266 entry.insert_boxed_with_operation(method_enum, route.handler, route.operation);
267 }
268
269 let route_count = by_path
270 .values()
271 .map(|mr| mr.allowed_methods().len())
272 .sum::<usize>();
273 let path_count = by_path.len();
274
275 for (path, method_router) in by_path {
276 self = self.route(&path, method_router);
277 }
278
279 tracing::info!(
280 paths = path_count,
281 routes = route_count,
282 "Auto-registered routes"
283 );
284
285 // Apply any auto-registered schemas.
286 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
287
288 self
289 }
290
291 /// Add a route
292 ///
293 /// # Example
294 ///
295 /// ```rust,ignore
296 /// RustApi::new()
297 /// .route("/", get(index))
298 /// .route("/users", get(list_users).post(create_user))
299 /// .route("/users/{id}", get(get_user).delete(delete_user))
300 /// ```
301 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
302 // Register operations in OpenAPI spec
303 for (method, op) in &method_router.operations {
304 let mut op = op.clone();
305 add_path_params_to_operation(path, &mut op);
306 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
307 }
308
309 self.router = self.router.route(path, method_router);
310 self
311 }
312
313 /// Mount a handler (convenience method)
314 ///
315 /// Alias for `.route(path, method_router)` for a single handler.
316 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
317 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
318 self.route(path, method_router)
319 }
320
321 /// Mount a route created with `#[rustapi::get]`, `#[rustapi::post]`, etc.
322 ///
323 /// # Example
324 ///
325 /// ```rust,ignore
326 /// use rustapi_rs::prelude::*;
327 ///
328 /// #[rustapi::get("/users")]
329 /// async fn list_users() -> Json<Vec<User>> {
330 /// Json(vec![])
331 /// }
332 ///
333 /// RustApi::new()
334 /// .mount_route(route!(list_users))
335 /// .run("127.0.0.1:8080")
336 /// .await
337 /// ```
338 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
339 let method_enum = match route.method {
340 "GET" => http::Method::GET,
341 "POST" => http::Method::POST,
342 "PUT" => http::Method::PUT,
343 "DELETE" => http::Method::DELETE,
344 "PATCH" => http::Method::PATCH,
345 _ => http::Method::GET,
346 };
347
348 // Register operation in OpenAPI spec
349 let mut op = route.operation;
350 add_path_params_to_operation(route.path, &mut op);
351 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
352
353 self.route_with_method(route.path, method_enum, route.handler)
354 }
355
356 /// Helper to mount a single method handler
357 fn route_with_method(
358 self,
359 path: &str,
360 method: http::Method,
361 handler: crate::handler::BoxedHandler,
362 ) -> Self {
363 use crate::router::MethodRouter;
364 // use http::Method; // Removed
365
366 // This is simplified. In a real implementation we'd merge with existing router at this path
367 // For now we assume one handler per path or we simply allow overwriting for this MVP step
368 // (matchit router doesn't allow easy merging/updating existing entries without rebuilding)
369 //
370 // TOOD: Enhance Router to support method merging
371
372 let path = if !path.starts_with('/') {
373 format!("/{}", path)
374 } else {
375 path.to_string()
376 };
377
378 // Check if we already have this path?
379 // For MVP, valid assumption: user calls .route() or .mount() once per path-method-combo
380 // But we need to handle multiple methods on same path.
381 // Our Router wrapper currently just inserts.
382
383 // Since we can't easily query matchit, we'll just insert.
384 // Limitations: strictly sequential mounting for now.
385
386 let mut handlers = std::collections::HashMap::new();
387 handlers.insert(method, handler);
388
389 let method_router = MethodRouter::from_boxed(handlers);
390 self.route(&path, method_router)
391 }
392
393 /// Nest a router under a prefix
394 ///
395 /// # Example
396 ///
397 /// ```rust,ignore
398 /// let api_v1 = Router::new()
399 /// .route("/users", get(list_users));
400 ///
401 /// RustApi::new()
402 /// .nest("/api/v1", api_v1)
403 /// ```
404 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
405 self.router = self.router.nest(prefix, router);
406 self
407 }
408
409 /// Serve static files from a directory
410 ///
411 /// Maps a URL path prefix to a filesystem directory. Requests to paths under
412 /// the prefix will serve files from the corresponding location in the directory.
413 ///
414 /// # Arguments
415 ///
416 /// * `prefix` - URL path prefix (e.g., "/static", "/assets")
417 /// * `root` - Filesystem directory path
418 ///
419 /// # Features
420 ///
421 /// - Automatic MIME type detection
422 /// - ETag and Last-Modified headers for caching
423 /// - Index file serving for directories
424 /// - Path traversal prevention
425 ///
426 /// # Example
427 ///
428 /// ```rust,ignore
429 /// use rustapi_rs::prelude::*;
430 ///
431 /// RustApi::new()
432 /// .serve_static("/assets", "./public")
433 /// .serve_static("/uploads", "./uploads")
434 /// .run("127.0.0.1:8080")
435 /// .await
436 /// ```
437 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
438 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
439 }
440
441 /// Serve static files with custom configuration
442 ///
443 /// # Example
444 ///
445 /// ```rust,ignore
446 /// use rustapi_core::static_files::StaticFileConfig;
447 ///
448 /// let config = StaticFileConfig::new("./public", "/assets")
449 /// .max_age(86400) // Cache for 1 day
450 /// .fallback("index.html"); // SPA fallback
451 ///
452 /// RustApi::new()
453 /// .serve_static_with_config(config)
454 /// .run("127.0.0.1:8080")
455 /// .await
456 /// ```
457 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
458 use crate::router::MethodRouter;
459 use std::collections::HashMap;
460
461 let prefix = config.prefix.clone();
462 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
463
464 // Create the static file handler
465 let handler: crate::handler::BoxedHandler =
466 std::sync::Arc::new(move |req: crate::Request| {
467 let config = config.clone();
468 let path = req.uri().path().to_string();
469
470 Box::pin(async move {
471 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
472
473 match crate::static_files::StaticFile::serve(relative_path, &config).await {
474 Ok(response) => response,
475 Err(err) => err.into_response(),
476 }
477 })
478 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
479 });
480
481 let mut handlers = HashMap::new();
482 handlers.insert(http::Method::GET, handler);
483 let method_router = MethodRouter::from_boxed(handlers);
484
485 self.route(&catch_all_path, method_router)
486 }
487
488 /// Enable response compression
489 ///
490 /// Adds gzip/deflate compression for response bodies. The compression
491 /// is based on the client's Accept-Encoding header.
492 ///
493 /// # Example
494 ///
495 /// ```rust,ignore
496 /// use rustapi_rs::prelude::*;
497 ///
498 /// RustApi::new()
499 /// .compression()
500 /// .route("/", get(handler))
501 /// .run("127.0.0.1:8080")
502 /// .await
503 /// ```
504 #[cfg(feature = "compression")]
505 pub fn compression(self) -> Self {
506 self.layer(crate::middleware::CompressionLayer::new())
507 }
508
509 /// Enable response compression with custom configuration
510 ///
511 /// # Example
512 ///
513 /// ```rust,ignore
514 /// use rustapi_core::middleware::CompressionConfig;
515 ///
516 /// RustApi::new()
517 /// .compression_with_config(
518 /// CompressionConfig::new()
519 /// .min_size(512)
520 /// .level(9)
521 /// )
522 /// .route("/", get(handler))
523 /// ```
524 #[cfg(feature = "compression")]
525 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
526 self.layer(crate::middleware::CompressionLayer::with_config(config))
527 }
528
529 /// Enable Swagger UI documentation
530 ///
531 /// This adds two endpoints:
532 /// - `{path}` - Swagger UI interface
533 /// - `{path}/openapi.json` - OpenAPI JSON specification
534 ///
535 /// # Example
536 ///
537 /// ```text
538 /// RustApi::new()
539 /// .route("/users", get(list_users))
540 /// .docs("/docs") // Swagger UI at /docs, spec at /docs/openapi.json
541 /// .run("127.0.0.1:8080")
542 /// .await
543 /// ```
544 #[cfg(feature = "swagger-ui")]
545 pub fn docs(self, path: &str) -> Self {
546 let title = self.openapi_spec.info.title.clone();
547 let version = self.openapi_spec.info.version.clone();
548 let description = self.openapi_spec.info.description.clone();
549
550 self.docs_with_info(path, &title, &version, description.as_deref())
551 }
552
553 /// Enable Swagger UI documentation with custom API info
554 ///
555 /// # Example
556 ///
557 /// ```rust,ignore
558 /// RustApi::new()
559 /// .docs_with_info("/docs", "My API", "2.0.0", Some("API for managing users"))
560 /// ```
561 #[cfg(feature = "swagger-ui")]
562 pub fn docs_with_info(
563 mut self,
564 path: &str,
565 title: &str,
566 version: &str,
567 description: Option<&str>,
568 ) -> Self {
569 use crate::router::get;
570 // Update spec info
571 self.openapi_spec.info.title = title.to_string();
572 self.openapi_spec.info.version = version.to_string();
573 if let Some(desc) = description {
574 self.openapi_spec.info.description = Some(desc.to_string());
575 }
576
577 let path = path.trim_end_matches('/');
578 let openapi_path = format!("{}/openapi.json", path);
579
580 // Clone values for closures
581 let spec_json =
582 serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
583 let openapi_url = openapi_path.clone();
584
585 // Add OpenAPI JSON endpoint
586 let spec_handler = move || {
587 let json = spec_json.clone();
588 async move {
589 http::Response::builder()
590 .status(http::StatusCode::OK)
591 .header(http::header::CONTENT_TYPE, "application/json")
592 .body(http_body_util::Full::new(bytes::Bytes::from(json)))
593 .unwrap()
594 }
595 };
596
597 // Add Swagger UI endpoint
598 let docs_handler = move || {
599 let url = openapi_url.clone();
600 async move { rustapi_openapi::swagger_ui_html(&url) }
601 };
602
603 self.route(&openapi_path, get(spec_handler))
604 .route(path, get(docs_handler))
605 }
606
607 /// Enable Swagger UI documentation with Basic Auth protection
608 ///
609 /// When username and password are provided, the docs endpoint will require
610 /// Basic Authentication. This is useful for protecting API documentation
611 /// in production environments.
612 ///
613 /// # Example
614 ///
615 /// ```rust,ignore
616 /// RustApi::new()
617 /// .route("/users", get(list_users))
618 /// .docs_with_auth("/docs", "admin", "secret123")
619 /// .run("127.0.0.1:8080")
620 /// .await
621 /// ```
622 #[cfg(feature = "swagger-ui")]
623 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
624 let title = self.openapi_spec.info.title.clone();
625 let version = self.openapi_spec.info.version.clone();
626 let description = self.openapi_spec.info.description.clone();
627
628 self.docs_with_auth_and_info(
629 path,
630 username,
631 password,
632 &title,
633 &version,
634 description.as_deref(),
635 )
636 }
637
638 /// Enable Swagger UI documentation with Basic Auth and custom API info
639 ///
640 /// # Example
641 ///
642 /// ```rust,ignore
643 /// RustApi::new()
644 /// .docs_with_auth_and_info(
645 /// "/docs",
646 /// "admin",
647 /// "secret",
648 /// "My API",
649 /// "2.0.0",
650 /// Some("Protected API documentation")
651 /// )
652 /// ```
653 #[cfg(feature = "swagger-ui")]
654 pub fn docs_with_auth_and_info(
655 mut self,
656 path: &str,
657 username: &str,
658 password: &str,
659 title: &str,
660 version: &str,
661 description: Option<&str>,
662 ) -> Self {
663 use crate::router::MethodRouter;
664 use base64::{engine::general_purpose::STANDARD, Engine};
665 use std::collections::HashMap;
666
667 // Update spec info
668 self.openapi_spec.info.title = title.to_string();
669 self.openapi_spec.info.version = version.to_string();
670 if let Some(desc) = description {
671 self.openapi_spec.info.description = Some(desc.to_string());
672 }
673
674 let path = path.trim_end_matches('/');
675 let openapi_path = format!("{}/openapi.json", path);
676
677 // Create expected auth header value
678 let credentials = format!("{}:{}", username, password);
679 let encoded = STANDARD.encode(credentials.as_bytes());
680 let expected_auth = format!("Basic {}", encoded);
681
682 // Clone values for closures
683 let spec_json =
684 serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
685 let openapi_url = openapi_path.clone();
686 let expected_auth_spec = expected_auth.clone();
687 let expected_auth_docs = expected_auth;
688
689 // Create spec handler with auth check
690 let spec_handler: crate::handler::BoxedHandler =
691 std::sync::Arc::new(move |req: crate::Request| {
692 let json = spec_json.clone();
693 let expected = expected_auth_spec.clone();
694 Box::pin(async move {
695 if !check_basic_auth(&req, &expected) {
696 return unauthorized_response();
697 }
698 http::Response::builder()
699 .status(http::StatusCode::OK)
700 .header(http::header::CONTENT_TYPE, "application/json")
701 .body(http_body_util::Full::new(bytes::Bytes::from(json)))
702 .unwrap()
703 })
704 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
705 });
706
707 // Create docs handler with auth check
708 let docs_handler: crate::handler::BoxedHandler =
709 std::sync::Arc::new(move |req: crate::Request| {
710 let url = openapi_url.clone();
711 let expected = expected_auth_docs.clone();
712 Box::pin(async move {
713 if !check_basic_auth(&req, &expected) {
714 return unauthorized_response();
715 }
716 rustapi_openapi::swagger_ui_html(&url)
717 })
718 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
719 });
720
721 // Create method routers with boxed handlers
722 let mut spec_handlers = HashMap::new();
723 spec_handlers.insert(http::Method::GET, spec_handler);
724 let spec_router = MethodRouter::from_boxed(spec_handlers);
725
726 let mut docs_handlers = HashMap::new();
727 docs_handlers.insert(http::Method::GET, docs_handler);
728 let docs_router = MethodRouter::from_boxed(docs_handlers);
729
730 self.route(&openapi_path, spec_router)
731 .route(path, docs_router)
732 }
733
734 /// Run the server
735 ///
736 /// # Example
737 ///
738 /// ```rust,ignore
739 /// RustApi::new()
740 /// .route("/", get(hello))
741 /// .run("127.0.0.1:8080")
742 /// .await
743 /// ```
744 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
745 // Apply body limit layer if configured (should be first in the chain)
746 if let Some(limit) = self.body_limit {
747 // Prepend body limit layer so it's the first to process requests
748 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
749 }
750
751 let server = Server::new(self.router, self.layers);
752 server.run(addr).await
753 }
754
755 /// Get the inner router (for testing or advanced usage)
756 pub fn into_router(self) -> Router {
757 self.router
758 }
759
760 /// Get the layer stack (for testing)
761 pub fn layers(&self) -> &LayerStack {
762 &self.layers
763 }
764}
765
766fn add_path_params_to_operation(path: &str, op: &mut rustapi_openapi::Operation) {
767 let mut params: Vec<String> = Vec::new();
768 let mut in_brace = false;
769 let mut current = String::new();
770
771 for ch in path.chars() {
772 match ch {
773 '{' => {
774 in_brace = true;
775 current.clear();
776 }
777 '}' => {
778 if in_brace {
779 in_brace = false;
780 if !current.is_empty() {
781 params.push(current.clone());
782 }
783 }
784 }
785 _ => {
786 if in_brace {
787 current.push(ch);
788 }
789 }
790 }
791 }
792
793 if params.is_empty() {
794 return;
795 }
796
797 let op_params = op.parameters.get_or_insert_with(Vec::new);
798
799 for name in params {
800 let already = op_params
801 .iter()
802 .any(|p| p.location == "path" && p.name == name);
803 if already {
804 continue;
805 }
806
807 op_params.push(rustapi_openapi::Parameter {
808 name,
809 location: "path".to_string(),
810 required: true,
811 description: None,
812 schema: rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" })),
813 });
814 }
815}
816
817impl Default for RustApi {
818 fn default() -> Self {
819 Self::new()
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use super::RustApi;
826 use crate::extract::{FromRequestParts, State};
827 use crate::request::Request;
828 use bytes::Bytes;
829 use http::Method;
830 use std::collections::HashMap;
831
832 #[test]
833 fn state_is_available_via_extractor() {
834 let app = RustApi::new().state(123u32);
835 let router = app.into_router();
836
837 let req = http::Request::builder()
838 .method(Method::GET)
839 .uri("/test")
840 .body(())
841 .unwrap();
842 let (parts, _) = req.into_parts();
843
844 let request = Request::new(parts, Bytes::new(), router.state_ref(), HashMap::new());
845 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
846 assert_eq!(value, 123u32);
847 }
848}
849
850/// Check Basic Auth header against expected credentials
851#[cfg(feature = "swagger-ui")]
852fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
853 req.headers()
854 .get(http::header::AUTHORIZATION)
855 .and_then(|v| v.to_str().ok())
856 .map(|auth| auth == expected)
857 .unwrap_or(false)
858}
859
860/// Create 401 Unauthorized response with WWW-Authenticate header
861#[cfg(feature = "swagger-ui")]
862fn unauthorized_response() -> crate::Response {
863 http::Response::builder()
864 .status(http::StatusCode::UNAUTHORIZED)
865 .header(
866 http::header::WWW_AUTHENTICATE,
867 "Basic realm=\"API Documentation\"",
868 )
869 .header(http::header::CONTENT_TYPE, "text/plain")
870 .body(http_body_util::Full::new(bytes::Bytes::from(
871 "Unauthorized",
872 )))
873 .unwrap()
874}
875
876/// Configuration builder for RustAPI with auto-routes
877pub struct RustApiConfig {
878 docs_path: Option<String>,
879 docs_enabled: bool,
880 api_title: String,
881 api_version: String,
882 api_description: Option<String>,
883 body_limit: Option<usize>,
884 layers: LayerStack,
885}
886
887impl Default for RustApiConfig {
888 fn default() -> Self {
889 Self::new()
890 }
891}
892
893impl RustApiConfig {
894 pub fn new() -> Self {
895 Self {
896 docs_path: Some("/docs".to_string()),
897 docs_enabled: true,
898 api_title: "RustAPI".to_string(),
899 api_version: "1.0.0".to_string(),
900 api_description: None,
901 body_limit: None,
902 layers: LayerStack::new(),
903 }
904 }
905
906 /// Set the docs path (default: "/docs")
907 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
908 self.docs_path = Some(path.into());
909 self
910 }
911
912 /// Enable or disable docs (default: true)
913 pub fn docs_enabled(mut self, enabled: bool) -> Self {
914 self.docs_enabled = enabled;
915 self
916 }
917
918 /// Set OpenAPI info
919 pub fn openapi_info(
920 mut self,
921 title: impl Into<String>,
922 version: impl Into<String>,
923 description: Option<impl Into<String>>,
924 ) -> Self {
925 self.api_title = title.into();
926 self.api_version = version.into();
927 self.api_description = description.map(|d| d.into());
928 self
929 }
930
931 /// Set body size limit
932 pub fn body_limit(mut self, limit: usize) -> Self {
933 self.body_limit = Some(limit);
934 self
935 }
936
937 /// Add a middleware layer
938 pub fn layer<L>(mut self, layer: L) -> Self
939 where
940 L: MiddlewareLayer,
941 {
942 self.layers.push(Box::new(layer));
943 self
944 }
945
946 /// Build the RustApi instance
947 pub fn build(self) -> RustApi {
948 let mut app = RustApi::new().mount_auto_routes_grouped();
949
950 // Apply configuration
951 if let Some(limit) = self.body_limit {
952 app = app.body_limit(limit);
953 }
954
955 app = app.openapi_info(
956 &self.api_title,
957 &self.api_version,
958 self.api_description.as_deref(),
959 );
960
961 #[cfg(feature = "swagger-ui")]
962 if self.docs_enabled {
963 if let Some(path) = self.docs_path {
964 app = app.docs(&path);
965 }
966 }
967
968 // Apply layers
969 // Note: layers are applied in reverse order in RustApi::layer logic (pushing to vec)
970 app.layers.extend(self.layers);
971
972 app
973 }
974
975 /// Build and run the server
976 pub async fn run(
977 self,
978 addr: impl AsRef<str>,
979 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
980 self.build().run(addr.as_ref()).await
981 }
982}