1use crate::error::Result;
4use crate::events::LifecycleHooks;
5use crate::interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor};
6use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT};
7use crate::response::IntoResponse;
8use crate::router::{MethodRouter, Router};
9use crate::server::Server;
10use std::collections::BTreeMap;
11use std::future::Future;
12use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
13
14pub struct RustApi {
32 router: Router,
33 openapi_spec: rustapi_openapi::OpenApiSpec,
34 layers: LayerStack,
35 body_limit: Option<usize>,
36 interceptors: InterceptorChain,
37 lifecycle_hooks: LifecycleHooks,
38 hot_reload: bool,
39 #[cfg(feature = "http3")]
40 http3_config: Option<crate::http3::Http3Config>,
41 health_check: Option<crate::health::HealthCheck>,
42 health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
43 status_config: Option<crate::status::StatusConfig>,
44}
45
46#[derive(Debug, Clone)]
54pub struct ProductionDefaultsConfig {
55 service_name: String,
56 version: Option<String>,
57 tracing_level: tracing::Level,
58 health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
59 enable_request_id: bool,
60 enable_tracing: bool,
61 enable_health_endpoints: bool,
62}
63
64impl ProductionDefaultsConfig {
65 pub fn new(service_name: impl Into<String>) -> Self {
67 Self {
68 service_name: service_name.into(),
69 version: None,
70 tracing_level: tracing::Level::INFO,
71 health_endpoint_config: None,
72 enable_request_id: true,
73 enable_tracing: true,
74 enable_health_endpoints: true,
75 }
76 }
77
78 pub fn version(mut self, version: impl Into<String>) -> Self {
80 self.version = Some(version.into());
81 self
82 }
83
84 pub fn tracing_level(mut self, level: tracing::Level) -> Self {
86 self.tracing_level = level;
87 self
88 }
89
90 pub fn health_endpoint_config(mut self, config: crate::health::HealthEndpointConfig) -> Self {
92 self.health_endpoint_config = Some(config);
93 self
94 }
95
96 pub fn request_id(mut self, enabled: bool) -> Self {
98 self.enable_request_id = enabled;
99 self
100 }
101
102 pub fn tracing(mut self, enabled: bool) -> Self {
104 self.enable_tracing = enabled;
105 self
106 }
107
108 pub fn health_endpoints(mut self, enabled: bool) -> Self {
110 self.enable_health_endpoints = enabled;
111 self
112 }
113}
114
115impl RustApi {
116 pub fn new() -> Self {
118 let _ = tracing_subscriber::registry()
120 .with(
121 EnvFilter::try_from_default_env()
122 .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
123 )
124 .with(tracing_subscriber::fmt::layer())
125 .try_init();
126
127 Self {
128 router: Router::new(),
129 openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
130 .register::<rustapi_openapi::ErrorSchema>()
131 .register::<rustapi_openapi::ErrorBodySchema>()
132 .register::<rustapi_openapi::ValidationErrorSchema>()
133 .register::<rustapi_openapi::ValidationErrorBodySchema>()
134 .register::<rustapi_openapi::FieldErrorSchema>(),
135 layers: LayerStack::new(),
136 body_limit: Some(DEFAULT_BODY_LIMIT), interceptors: InterceptorChain::new(),
138 lifecycle_hooks: LifecycleHooks::new(),
139 hot_reload: false,
140 #[cfg(feature = "http3")]
141 http3_config: None,
142 health_check: None,
143 health_endpoint_config: None,
144 status_config: None,
145 }
146 }
147
148 #[cfg(feature = "swagger-ui")]
172 pub fn auto() -> Self {
173 Self::new().mount_auto_routes_grouped().docs("/docs")
175 }
176
177 #[cfg(not(feature = "swagger-ui"))]
182 pub fn auto() -> Self {
183 Self::new().mount_auto_routes_grouped()
184 }
185
186 pub fn config() -> RustApiConfig {
204 RustApiConfig::new()
205 }
206
207 pub fn body_limit(mut self, limit: usize) -> Self {
228 self.body_limit = Some(limit);
229 self
230 }
231
232 pub fn no_body_limit(mut self) -> Self {
245 self.body_limit = None;
246 self
247 }
248
249 pub fn layer<L>(mut self, layer: L) -> Self
269 where
270 L: MiddlewareLayer,
271 {
272 self.layers.push(Box::new(layer));
273 self
274 }
275
276 pub fn request_interceptor<I>(mut self, interceptor: I) -> Self
308 where
309 I: RequestInterceptor,
310 {
311 self.interceptors.add_request_interceptor(interceptor);
312 self
313 }
314
315 pub fn response_interceptor<I>(mut self, interceptor: I) -> Self
347 where
348 I: ResponseInterceptor,
349 {
350 self.interceptors.add_response_interceptor(interceptor);
351 self
352 }
353
354 pub fn state<S>(self, _state: S) -> Self
370 where
371 S: Clone + Send + Sync + 'static,
372 {
373 let state = _state;
375 let mut app = self;
376 app.router = app.router.state(state);
377 app
378 }
379
380 pub fn on_start<F, Fut>(mut self, hook: F) -> Self
397 where
398 F: FnOnce() -> Fut + Send + 'static,
399 Fut: Future<Output = ()> + Send + 'static,
400 {
401 self.lifecycle_hooks
402 .on_start
403 .push(Box::new(move || Box::pin(hook())));
404 self
405 }
406
407 pub fn on_shutdown<F, Fut>(mut self, hook: F) -> Self
424 where
425 F: FnOnce() -> Fut + Send + 'static,
426 Fut: Future<Output = ()> + Send + 'static,
427 {
428 self.lifecycle_hooks
429 .on_shutdown
430 .push(Box::new(move || Box::pin(hook())));
431 self
432 }
433
434 pub fn hot_reload(mut self, enabled: bool) -> Self {
453 self.hot_reload = enabled;
454 self
455 }
456
457 pub fn register_schema<T: rustapi_openapi::schema::RustApiSchema>(mut self) -> Self {
469 self.openapi_spec = self.openapi_spec.register::<T>();
470 self
471 }
472
473 pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
475 self.openapi_spec.info.title = title.to_string();
478 self.openapi_spec.info.version = version.to_string();
479 self.openapi_spec.info.description = description.map(|d| d.to_string());
480 self
481 }
482
483 pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
485 &self.openapi_spec
486 }
487
488 fn mount_auto_routes_grouped(mut self) -> Self {
489 let routes = crate::auto_route::collect_auto_routes();
490 let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();
492
493 for route in routes {
494 let crate::handler::Route {
495 path: route_path,
496 method,
497 handler,
498 operation,
499 component_registrar,
500 ..
501 } = route;
502
503 let method_enum = match method {
504 "GET" => http::Method::GET,
505 "POST" => http::Method::POST,
506 "PUT" => http::Method::PUT,
507 "DELETE" => http::Method::DELETE,
508 "PATCH" => http::Method::PATCH,
509 _ => http::Method::GET,
510 };
511
512 let path = if route_path.starts_with('/') {
513 route_path.to_string()
514 } else {
515 format!("/{}", route_path)
516 };
517
518 let entry = by_path.entry(path).or_default();
519 entry.insert_boxed_with_operation(method_enum, handler, operation, component_registrar);
520 }
521
522 #[cfg(feature = "tracing")]
523 let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
524 #[cfg(feature = "tracing")]
525 let path_count = by_path.len();
526
527 for (path, method_router) in by_path {
528 self = self.route(&path, method_router);
529 }
530
531 crate::trace_info!(
532 paths = path_count,
533 routes = route_count,
534 "Auto-registered routes"
535 );
536
537 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
539
540 self
541 }
542
543 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
554 for register_components in &method_router.component_registrars {
555 register_components(&mut self.openapi_spec);
556 }
557
558 for (method, op) in &method_router.operations {
560 let mut op = op.clone();
561 add_path_params_to_operation(path, &mut op, &BTreeMap::new());
562 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
563 }
564
565 self.router = self.router.route(path, method_router);
566 self
567 }
568
569 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
571 self.route(P::PATH, method_router)
572 }
573
574 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
578 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
579 self.route(path, method_router)
580 }
581
582 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
600 let method_enum = match route.method {
601 "GET" => http::Method::GET,
602 "POST" => http::Method::POST,
603 "PUT" => http::Method::PUT,
604 "DELETE" => http::Method::DELETE,
605 "PATCH" => http::Method::PATCH,
606 _ => http::Method::GET,
607 };
608
609 (route.component_registrar)(&mut self.openapi_spec);
610
611 let mut op = route.operation;
613 add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
614 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
615
616 self.route_with_method(route.path, method_enum, route.handler)
617 }
618
619 fn route_with_method(
621 self,
622 path: &str,
623 method: http::Method,
624 handler: crate::handler::BoxedHandler,
625 ) -> Self {
626 use crate::router::MethodRouter;
627 let path = if !path.starts_with('/') {
636 format!("/{}", path)
637 } else {
638 path.to_string()
639 };
640
641 let mut handlers = std::collections::HashMap::new();
650 handlers.insert(method, handler);
651
652 let method_router = MethodRouter::from_boxed(handlers);
653 self.route(&path, method_router)
654 }
655
656 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
672 let normalized_prefix = normalize_prefix_for_openapi(prefix);
674
675 for (matchit_path, method_router) in router.method_routers() {
678 for register_components in &method_router.component_registrars {
679 register_components(&mut self.openapi_spec);
680 }
681
682 let display_path = router
684 .registered_routes()
685 .get(matchit_path)
686 .map(|info| info.path.clone())
687 .unwrap_or_else(|| matchit_path.clone());
688
689 let prefixed_path = if display_path == "/" {
691 normalized_prefix.clone()
692 } else {
693 format!("{}{}", normalized_prefix, display_path)
694 };
695
696 for (method, op) in &method_router.operations {
698 let mut op = op.clone();
699 add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
700 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
701 }
702 }
703
704 self.router = self.router.nest(prefix, router);
706 self
707 }
708
709 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
738 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
739 }
740
741 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
758 use crate::router::MethodRouter;
759 use std::collections::HashMap;
760
761 let prefix = config.prefix.clone();
762 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
763
764 let handler: crate::handler::BoxedHandler =
766 std::sync::Arc::new(move |req: crate::Request| {
767 let config = config.clone();
768 let path = req.uri().path().to_string();
769
770 Box::pin(async move {
771 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
772
773 match crate::static_files::StaticFile::serve(relative_path, &config).await {
774 Ok(response) => response,
775 Err(err) => err.into_response(),
776 }
777 })
778 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
779 });
780
781 let mut handlers = HashMap::new();
782 handlers.insert(http::Method::GET, handler);
783 let method_router = MethodRouter::from_boxed(handlers);
784
785 self.route(&catch_all_path, method_router)
786 }
787
788 #[cfg(feature = "compression")]
805 pub fn compression(self) -> Self {
806 self.layer(crate::middleware::CompressionLayer::new())
807 }
808
809 #[cfg(feature = "compression")]
825 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
826 self.layer(crate::middleware::CompressionLayer::with_config(config))
827 }
828
829 #[cfg(feature = "swagger-ui")]
853 pub fn docs(self, path: &str) -> Self {
854 let title = self.openapi_spec.info.title.clone();
855 let version = self.openapi_spec.info.version.clone();
856 let description = self.openapi_spec.info.description.clone();
857
858 self.docs_with_info(path, &title, &version, description.as_deref())
859 }
860
861 #[cfg(feature = "swagger-ui")]
870 pub fn docs_with_info(
871 mut self,
872 path: &str,
873 title: &str,
874 version: &str,
875 description: Option<&str>,
876 ) -> Self {
877 use crate::router::get;
878 self.openapi_spec.info.title = title.to_string();
880 self.openapi_spec.info.version = version.to_string();
881 if let Some(desc) = description {
882 self.openapi_spec.info.description = Some(desc.to_string());
883 }
884
885 let path = path.trim_end_matches('/');
886 let openapi_path = format!("{}/openapi.json", path);
887
888 let spec_value = self.openapi_spec.to_json();
890 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
891 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
893 "{}".to_string()
894 });
895 let openapi_url = openapi_path.clone();
896
897 let spec_handler = move || {
899 let json = spec_json.clone();
900 async move {
901 http::Response::builder()
902 .status(http::StatusCode::OK)
903 .header(http::header::CONTENT_TYPE, "application/json")
904 .body(crate::response::Body::from(json))
905 .unwrap_or_else(|e| {
906 tracing::error!("Failed to build response: {}", e);
907 http::Response::builder()
908 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
909 .body(crate::response::Body::from("Internal Server Error"))
910 .unwrap()
911 })
912 }
913 };
914
915 let docs_handler = move || {
917 let url = openapi_url.clone();
918 async move {
919 let response = rustapi_openapi::swagger_ui_html(&url);
920 response.map(crate::response::Body::Full)
921 }
922 };
923
924 self.route(&openapi_path, get(spec_handler))
925 .route(path, get(docs_handler))
926 }
927
928 #[cfg(feature = "swagger-ui")]
944 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
945 let title = self.openapi_spec.info.title.clone();
946 let version = self.openapi_spec.info.version.clone();
947 let description = self.openapi_spec.info.description.clone();
948
949 self.docs_with_auth_and_info(
950 path,
951 username,
952 password,
953 &title,
954 &version,
955 description.as_deref(),
956 )
957 }
958
959 #[cfg(feature = "swagger-ui")]
975 pub fn docs_with_auth_and_info(
976 mut self,
977 path: &str,
978 username: &str,
979 password: &str,
980 title: &str,
981 version: &str,
982 description: Option<&str>,
983 ) -> Self {
984 use crate::router::MethodRouter;
985 use base64::{engine::general_purpose::STANDARD, Engine};
986 use std::collections::HashMap;
987
988 self.openapi_spec.info.title = title.to_string();
990 self.openapi_spec.info.version = version.to_string();
991 if let Some(desc) = description {
992 self.openapi_spec.info.description = Some(desc.to_string());
993 }
994
995 let path = path.trim_end_matches('/');
996 let openapi_path = format!("{}/openapi.json", path);
997
998 let credentials = format!("{}:{}", username, password);
1000 let encoded = STANDARD.encode(credentials.as_bytes());
1001 let expected_auth = format!("Basic {}", encoded);
1002
1003 let spec_value = self.openapi_spec.to_json();
1005 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
1006 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
1007 "{}".to_string()
1008 });
1009 let openapi_url = openapi_path.clone();
1010 let expected_auth_spec = expected_auth.clone();
1011 let expected_auth_docs = expected_auth;
1012
1013 let spec_handler: crate::handler::BoxedHandler =
1015 std::sync::Arc::new(move |req: crate::Request| {
1016 let json = spec_json.clone();
1017 let expected = expected_auth_spec.clone();
1018 Box::pin(async move {
1019 if !check_basic_auth(&req, &expected) {
1020 return unauthorized_response();
1021 }
1022 http::Response::builder()
1023 .status(http::StatusCode::OK)
1024 .header(http::header::CONTENT_TYPE, "application/json")
1025 .body(crate::response::Body::from(json))
1026 .unwrap_or_else(|e| {
1027 tracing::error!("Failed to build response: {}", e);
1028 http::Response::builder()
1029 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1030 .body(crate::response::Body::from("Internal Server Error"))
1031 .unwrap()
1032 })
1033 })
1034 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1035 });
1036
1037 let docs_handler: crate::handler::BoxedHandler =
1039 std::sync::Arc::new(move |req: crate::Request| {
1040 let url = openapi_url.clone();
1041 let expected = expected_auth_docs.clone();
1042 Box::pin(async move {
1043 if !check_basic_auth(&req, &expected) {
1044 return unauthorized_response();
1045 }
1046 let response = rustapi_openapi::swagger_ui_html(&url);
1047 response.map(crate::response::Body::Full)
1048 })
1049 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1050 });
1051
1052 let mut spec_handlers = HashMap::new();
1054 spec_handlers.insert(http::Method::GET, spec_handler);
1055 let spec_router = MethodRouter::from_boxed(spec_handlers);
1056
1057 let mut docs_handlers = HashMap::new();
1058 docs_handlers.insert(http::Method::GET, docs_handler);
1059 let docs_router = MethodRouter::from_boxed(docs_handlers);
1060
1061 self.route(&openapi_path, spec_router)
1062 .route(path, docs_router)
1063 }
1064
1065 pub fn status_page(self) -> Self {
1067 self.status_page_with_config(crate::status::StatusConfig::default())
1068 }
1069
1070 pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self {
1072 self.status_config = Some(config);
1073 self
1074 }
1075
1076 pub fn health_endpoints(mut self) -> Self {
1081 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1082 if self.health_check.is_none() {
1083 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1084 }
1085 self
1086 }
1087
1088 pub fn health_endpoints_with_config(
1090 mut self,
1091 config: crate::health::HealthEndpointConfig,
1092 ) -> Self {
1093 self.health_endpoint_config = Some(config);
1094 if self.health_check.is_none() {
1095 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1096 }
1097 self
1098 }
1099
1100 pub fn with_health_check(mut self, health_check: crate::health::HealthCheck) -> Self {
1105 self.health_check = Some(health_check);
1106 if self.health_endpoint_config.is_none() {
1107 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1108 }
1109 self
1110 }
1111
1112 pub fn production_defaults(self, service_name: impl Into<String>) -> Self {
1119 self.production_defaults_with_config(ProductionDefaultsConfig::new(service_name))
1120 }
1121
1122 pub fn production_defaults_with_config(mut self, config: ProductionDefaultsConfig) -> Self {
1124 if config.enable_request_id {
1125 self = self.layer(crate::middleware::RequestIdLayer::new());
1126 }
1127
1128 if config.enable_tracing {
1129 let mut tracing_layer =
1130 crate::middleware::TracingLayer::with_level(config.tracing_level)
1131 .with_field("service", config.service_name.clone())
1132 .with_field("environment", crate::error::get_environment().to_string());
1133
1134 if let Some(version) = &config.version {
1135 tracing_layer = tracing_layer.with_field("version", version.clone());
1136 }
1137
1138 self = self.layer(tracing_layer);
1139 }
1140
1141 if config.enable_health_endpoints {
1142 if self.health_check.is_none() {
1143 let mut builder = crate::health::HealthCheckBuilder::default();
1144 if let Some(version) = &config.version {
1145 builder = builder.version(version.clone());
1146 }
1147 self.health_check = Some(builder.build());
1148 }
1149
1150 if self.health_endpoint_config.is_none() {
1151 self.health_endpoint_config =
1152 Some(config.health_endpoint_config.unwrap_or_default());
1153 }
1154 }
1155
1156 self
1157 }
1158
1159 fn print_hot_reload_banner(&self, addr: &str) {
1161 if !self.hot_reload {
1162 return;
1163 }
1164
1165 std::env::set_var("RUSTAPI_HOT_RELOAD", "1");
1167
1168 let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD")
1169 .map(|v| v == "1")
1170 .unwrap_or(false);
1171
1172 tracing::info!("🔄 Hot-reload mode enabled");
1173
1174 if is_under_watcher {
1175 tracing::info!(" File watcher active — changes will trigger rebuild + restart");
1176 } else {
1177 tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload");
1178 }
1179
1180 tracing::info!(" Listening on http://{addr}");
1181 }
1182
1183 fn apply_health_endpoints(&mut self) {
1185 if let Some(config) = &self.health_endpoint_config {
1186 use crate::router::get;
1187
1188 let health_check = self
1189 .health_check
1190 .clone()
1191 .unwrap_or_else(|| crate::health::HealthCheckBuilder::default().build());
1192
1193 let health_path = config.health_path.clone();
1194 let readiness_path = config.readiness_path.clone();
1195 let liveness_path = config.liveness_path.clone();
1196
1197 let health_handler = {
1198 let health_check = health_check.clone();
1199 move || {
1200 let health_check = health_check.clone();
1201 async move { crate::health::health_response(health_check).await }
1202 }
1203 };
1204
1205 let readiness_handler = {
1206 let health_check = health_check.clone();
1207 move || {
1208 let health_check = health_check.clone();
1209 async move { crate::health::readiness_response(health_check).await }
1210 }
1211 };
1212
1213 let liveness_handler = || async { crate::health::liveness_response().await };
1214
1215 let router = std::mem::take(&mut self.router);
1216 self.router = router
1217 .route(&health_path, get(health_handler))
1218 .route(&readiness_path, get(readiness_handler))
1219 .route(&liveness_path, get(liveness_handler));
1220 }
1221 }
1222
1223 fn apply_status_page(&mut self) {
1224 if let Some(config) = &self.status_config {
1225 let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new());
1226
1227 self.layers
1229 .push(Box::new(crate::status::StatusLayer::new(monitor.clone())));
1230
1231 use crate::router::MethodRouter;
1233 use std::collections::HashMap;
1234
1235 let monitor = monitor.clone();
1236 let config = config.clone();
1237 let path = config.path.clone(); let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| {
1240 let monitor = monitor.clone();
1241 let config = config.clone();
1242 Box::pin(async move {
1243 crate::status::status_handler(monitor, config)
1244 .await
1245 .into_response()
1246 })
1247 });
1248
1249 let mut handlers = HashMap::new();
1250 handlers.insert(http::Method::GET, handler);
1251 let method_router = MethodRouter::from_boxed(handlers);
1252
1253 let router = std::mem::take(&mut self.router);
1255 self.router = router.route(&path, method_router);
1256 }
1257 }
1258
1259 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1270 self.print_hot_reload_banner(addr);
1272
1273 self.apply_health_endpoints();
1275
1276 self.apply_status_page();
1278
1279 if let Some(limit) = self.body_limit {
1281 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1283 }
1284
1285 for hook in self.lifecycle_hooks.on_start {
1287 hook().await;
1288 }
1289
1290 let server = Server::new(self.router, self.layers, self.interceptors);
1291 server.run(addr).await
1292 }
1293
1294 pub async fn run_with_shutdown<F>(
1296 mut self,
1297 addr: impl AsRef<str>,
1298 signal: F,
1299 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1300 where
1301 F: std::future::Future<Output = ()> + Send + 'static,
1302 {
1303 self.print_hot_reload_banner(addr.as_ref());
1305
1306 self.apply_health_endpoints();
1308
1309 self.apply_status_page();
1311
1312 if let Some(limit) = self.body_limit {
1313 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1314 }
1315
1316 for hook in self.lifecycle_hooks.on_start {
1318 hook().await;
1319 }
1320
1321 let shutdown_hooks = self.lifecycle_hooks.on_shutdown;
1323 let wrapped_signal = async move {
1324 signal.await;
1325 for hook in shutdown_hooks {
1327 hook().await;
1328 }
1329 };
1330
1331 let server = Server::new(self.router, self.layers, self.interceptors);
1332 server
1333 .run_with_shutdown(addr.as_ref(), wrapped_signal)
1334 .await
1335 }
1336
1337 pub fn into_router(self) -> Router {
1339 self.router
1340 }
1341
1342 pub fn layers(&self) -> &LayerStack {
1344 &self.layers
1345 }
1346
1347 pub fn interceptors(&self) -> &InterceptorChain {
1349 &self.interceptors
1350 }
1351
1352 #[cfg(feature = "http3")]
1366 pub async fn run_http3(
1367 mut self,
1368 config: crate::http3::Http3Config,
1369 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1370 use std::sync::Arc;
1371
1372 self.apply_health_endpoints();
1374
1375 self.apply_status_page();
1377
1378 if let Some(limit) = self.body_limit {
1380 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1381 }
1382
1383 let server = crate::http3::Http3Server::new(
1384 &config,
1385 Arc::new(self.router),
1386 Arc::new(self.layers),
1387 Arc::new(self.interceptors),
1388 )
1389 .await?;
1390
1391 server.run().await
1392 }
1393
1394 #[cfg(feature = "http3-dev")]
1408 pub async fn run_http3_dev(
1409 mut self,
1410 addr: &str,
1411 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1412 use std::sync::Arc;
1413
1414 self.apply_health_endpoints();
1416
1417 self.apply_status_page();
1419
1420 if let Some(limit) = self.body_limit {
1422 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1423 }
1424
1425 let server = crate::http3::Http3Server::new_with_self_signed(
1426 addr,
1427 Arc::new(self.router),
1428 Arc::new(self.layers),
1429 Arc::new(self.interceptors),
1430 )
1431 .await?;
1432
1433 server.run().await
1434 }
1435
1436 #[cfg(feature = "http3")]
1447 pub fn with_http3(mut self, cert_path: impl Into<String>, key_path: impl Into<String>) -> Self {
1448 self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path));
1449 self
1450 }
1451
1452 #[cfg(feature = "http3")]
1467 pub async fn run_dual_stack(
1468 mut self,
1469 http_addr: &str,
1470 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1471 use std::sync::Arc;
1472
1473 let mut config = self
1474 .http3_config
1475 .take()
1476 .ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
1477
1478 let http_socket: std::net::SocketAddr = http_addr.parse()?;
1479 config.bind_addr = if http_socket.ip().is_ipv6() {
1480 format!("[{}]", http_socket.ip())
1481 } else {
1482 http_socket.ip().to_string()
1483 };
1484 config.port = http_socket.port();
1485 let http_addr = http_socket.to_string();
1486
1487 self.apply_health_endpoints();
1489
1490 self.apply_status_page();
1492
1493 if let Some(limit) = self.body_limit {
1495 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1496 }
1497
1498 let router = Arc::new(self.router);
1499 let layers = Arc::new(self.layers);
1500 let interceptors = Arc::new(self.interceptors);
1501
1502 let http1_server =
1503 Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1504 let http3_server =
1505 crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1506
1507 tracing::info!(
1508 http1_addr = %http_addr,
1509 http3_addr = %config.socket_addr(),
1510 "Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1511 );
1512
1513 tokio::try_join!(
1514 http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1515 http3_server.run_with_shutdown(std::future::pending::<()>()),
1516 )?;
1517
1518 Ok(())
1519 }
1520}
1521
1522fn add_path_params_to_operation(
1523 path: &str,
1524 op: &mut rustapi_openapi::Operation,
1525 param_schemas: &BTreeMap<String, String>,
1526) {
1527 let mut params: Vec<String> = Vec::new();
1528 let mut in_brace = false;
1529 let mut current = String::new();
1530
1531 for ch in path.chars() {
1532 match ch {
1533 '{' => {
1534 in_brace = true;
1535 current.clear();
1536 }
1537 '}' => {
1538 if in_brace {
1539 in_brace = false;
1540 if !current.is_empty() {
1541 params.push(current.clone());
1542 }
1543 }
1544 }
1545 _ => {
1546 if in_brace {
1547 current.push(ch);
1548 }
1549 }
1550 }
1551 }
1552
1553 if params.is_empty() {
1554 return;
1555 }
1556
1557 let op_params = &mut op.parameters;
1558
1559 for name in params {
1560 let already = op_params
1561 .iter()
1562 .any(|p| p.location == "path" && p.name == name);
1563 if already {
1564 continue;
1565 }
1566
1567 let schema = if let Some(schema_type) = param_schemas.get(&name) {
1569 schema_type_to_openapi_schema(schema_type)
1570 } else {
1571 infer_path_param_schema(&name)
1572 };
1573
1574 op_params.push(rustapi_openapi::Parameter {
1575 name,
1576 location: "path".to_string(),
1577 required: true,
1578 description: None,
1579 deprecated: None,
1580 schema: Some(schema),
1581 });
1582 }
1583}
1584
1585fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef {
1587 match schema_type.to_lowercase().as_str() {
1588 "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1589 "type": "string",
1590 "format": "uuid"
1591 })),
1592 "integer" | "int" | "int64" | "i64" => {
1593 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1594 "type": "integer",
1595 "format": "int64"
1596 }))
1597 }
1598 "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1599 "type": "integer",
1600 "format": "int32"
1601 })),
1602 "number" | "float" | "f64" | "f32" => {
1603 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1604 "type": "number"
1605 }))
1606 }
1607 "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1608 "type": "boolean"
1609 })),
1610 _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1611 "type": "string"
1612 })),
1613 }
1614}
1615
1616fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
1625 let lower = name.to_lowercase();
1626
1627 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
1629
1630 if is_uuid {
1631 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1632 "type": "string",
1633 "format": "uuid"
1634 }));
1635 }
1636
1637 let is_integer = lower == "page"
1640 || lower == "limit"
1641 || lower == "offset"
1642 || lower == "count"
1643 || lower.ends_with("_count")
1644 || lower.ends_with("_num")
1645 || lower == "year"
1646 || lower == "month"
1647 || lower == "day"
1648 || lower == "index"
1649 || lower == "position";
1650
1651 if is_integer {
1652 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1653 "type": "integer",
1654 "format": "int64"
1655 }))
1656 } else {
1657 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
1658 }
1659}
1660
1661fn normalize_prefix_for_openapi(prefix: &str) -> String {
1668 if prefix.is_empty() {
1670 return "/".to_string();
1671 }
1672
1673 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
1675
1676 if segments.is_empty() {
1678 return "/".to_string();
1679 }
1680
1681 let mut result = String::with_capacity(prefix.len() + 1);
1683 for segment in segments {
1684 result.push('/');
1685 result.push_str(segment);
1686 }
1687
1688 result
1689}
1690
1691impl Default for RustApi {
1692 fn default() -> Self {
1693 Self::new()
1694 }
1695}
1696
1697#[cfg(feature = "swagger-ui")]
1699fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1700 req.headers()
1701 .get(http::header::AUTHORIZATION)
1702 .and_then(|v| v.to_str().ok())
1703 .map(|auth| auth == expected)
1704 .unwrap_or(false)
1705}
1706
1707#[cfg(feature = "swagger-ui")]
1709fn unauthorized_response() -> crate::Response {
1710 http::Response::builder()
1711 .status(http::StatusCode::UNAUTHORIZED)
1712 .header(
1713 http::header::WWW_AUTHENTICATE,
1714 "Basic realm=\"API Documentation\"",
1715 )
1716 .header(http::header::CONTENT_TYPE, "text/plain")
1717 .body(crate::response::Body::from("Unauthorized"))
1718 .unwrap()
1719}
1720
1721pub struct RustApiConfig {
1723 docs_path: Option<String>,
1724 docs_enabled: bool,
1725 api_title: String,
1726 api_version: String,
1727 api_description: Option<String>,
1728 body_limit: Option<usize>,
1729 layers: LayerStack,
1730}
1731
1732impl Default for RustApiConfig {
1733 fn default() -> Self {
1734 Self::new()
1735 }
1736}
1737
1738impl RustApiConfig {
1739 pub fn new() -> Self {
1740 Self {
1741 docs_path: Some("/docs".to_string()),
1742 docs_enabled: true,
1743 api_title: "RustAPI".to_string(),
1744 api_version: "1.0.0".to_string(),
1745 api_description: None,
1746 body_limit: None,
1747 layers: LayerStack::new(),
1748 }
1749 }
1750
1751 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
1753 self.docs_path = Some(path.into());
1754 self
1755 }
1756
1757 pub fn docs_enabled(mut self, enabled: bool) -> Self {
1759 self.docs_enabled = enabled;
1760 self
1761 }
1762
1763 pub fn openapi_info(
1765 mut self,
1766 title: impl Into<String>,
1767 version: impl Into<String>,
1768 description: Option<impl Into<String>>,
1769 ) -> Self {
1770 self.api_title = title.into();
1771 self.api_version = version.into();
1772 self.api_description = description.map(|d| d.into());
1773 self
1774 }
1775
1776 pub fn body_limit(mut self, limit: usize) -> Self {
1778 self.body_limit = Some(limit);
1779 self
1780 }
1781
1782 pub fn layer<L>(mut self, layer: L) -> Self
1784 where
1785 L: MiddlewareLayer,
1786 {
1787 self.layers.push(Box::new(layer));
1788 self
1789 }
1790
1791 pub fn build(self) -> RustApi {
1793 let mut app = RustApi::new().mount_auto_routes_grouped();
1794
1795 if let Some(limit) = self.body_limit {
1797 app = app.body_limit(limit);
1798 }
1799
1800 app = app.openapi_info(
1801 &self.api_title,
1802 &self.api_version,
1803 self.api_description.as_deref(),
1804 );
1805
1806 #[cfg(feature = "swagger-ui")]
1807 if self.docs_enabled {
1808 if let Some(path) = self.docs_path {
1809 app = app.docs(&path);
1810 }
1811 }
1812
1813 app.layers.extend(self.layers);
1816
1817 app
1818 }
1819
1820 pub async fn run(
1822 self,
1823 addr: impl AsRef<str>,
1824 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1825 self.build().run(addr.as_ref()).await
1826 }
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831 use super::RustApi;
1832 use crate::extract::{FromRequestParts, State};
1833 use crate::path_params::PathParams;
1834 use crate::request::Request;
1835 use crate::router::{get, post, Router};
1836 use bytes::Bytes;
1837 use http::Method;
1838 use proptest::prelude::*;
1839
1840 #[test]
1841 fn state_is_available_via_extractor() {
1842 let app = RustApi::new().state(123u32);
1843 let router = app.into_router();
1844
1845 let req = http::Request::builder()
1846 .method(Method::GET)
1847 .uri("/test")
1848 .body(())
1849 .unwrap();
1850 let (parts, _) = req.into_parts();
1851
1852 let request = Request::new(
1853 parts,
1854 crate::request::BodyVariant::Buffered(Bytes::new()),
1855 router.state_ref(),
1856 PathParams::new(),
1857 );
1858 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
1859 assert_eq!(value, 123u32);
1860 }
1861
1862 #[test]
1863 fn test_path_param_type_inference_integer() {
1864 use super::infer_path_param_schema;
1865
1866 let int_params = [
1868 "page",
1869 "limit",
1870 "offset",
1871 "count",
1872 "item_count",
1873 "year",
1874 "month",
1875 "day",
1876 "index",
1877 "position",
1878 ];
1879
1880 for name in int_params {
1881 let schema = infer_path_param_schema(name);
1882 match schema {
1883 rustapi_openapi::SchemaRef::Inline(v) => {
1884 assert_eq!(
1885 v.get("type").and_then(|v| v.as_str()),
1886 Some("integer"),
1887 "Expected '{}' to be inferred as integer",
1888 name
1889 );
1890 }
1891 _ => panic!("Expected inline schema for '{}'", name),
1892 }
1893 }
1894 }
1895
1896 #[test]
1897 fn test_path_param_type_inference_uuid() {
1898 use super::infer_path_param_schema;
1899
1900 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
1902
1903 for name in uuid_params {
1904 let schema = infer_path_param_schema(name);
1905 match schema {
1906 rustapi_openapi::SchemaRef::Inline(v) => {
1907 assert_eq!(
1908 v.get("type").and_then(|v| v.as_str()),
1909 Some("string"),
1910 "Expected '{}' to be inferred as string",
1911 name
1912 );
1913 assert_eq!(
1914 v.get("format").and_then(|v| v.as_str()),
1915 Some("uuid"),
1916 "Expected '{}' to have uuid format",
1917 name
1918 );
1919 }
1920 _ => panic!("Expected inline schema for '{}'", name),
1921 }
1922 }
1923 }
1924
1925 #[test]
1926 fn test_path_param_type_inference_string() {
1927 use super::infer_path_param_schema;
1928
1929 let string_params = [
1931 "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId",
1932 ];
1933
1934 for name in string_params {
1935 let schema = infer_path_param_schema(name);
1936 match schema {
1937 rustapi_openapi::SchemaRef::Inline(v) => {
1938 assert_eq!(
1939 v.get("type").and_then(|v| v.as_str()),
1940 Some("string"),
1941 "Expected '{}' to be inferred as string",
1942 name
1943 );
1944 assert!(
1945 v.get("format").is_none()
1946 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
1947 "Expected '{}' to NOT have uuid format",
1948 name
1949 );
1950 }
1951 _ => panic!("Expected inline schema for '{}'", name),
1952 }
1953 }
1954 }
1955
1956 #[test]
1957 fn test_schema_type_to_openapi_schema() {
1958 use super::schema_type_to_openapi_schema;
1959
1960 let uuid_schema = schema_type_to_openapi_schema("uuid");
1962 match uuid_schema {
1963 rustapi_openapi::SchemaRef::Inline(v) => {
1964 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
1965 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid"));
1966 }
1967 _ => panic!("Expected inline schema for uuid"),
1968 }
1969
1970 for schema_type in ["integer", "int", "int64", "i64"] {
1972 let schema = schema_type_to_openapi_schema(schema_type);
1973 match schema {
1974 rustapi_openapi::SchemaRef::Inline(v) => {
1975 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
1976 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64"));
1977 }
1978 _ => panic!("Expected inline schema for {}", schema_type),
1979 }
1980 }
1981
1982 let int32_schema = schema_type_to_openapi_schema("int32");
1984 match int32_schema {
1985 rustapi_openapi::SchemaRef::Inline(v) => {
1986 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
1987 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32"));
1988 }
1989 _ => panic!("Expected inline schema for int32"),
1990 }
1991
1992 for schema_type in ["number", "float"] {
1994 let schema = schema_type_to_openapi_schema(schema_type);
1995 match schema {
1996 rustapi_openapi::SchemaRef::Inline(v) => {
1997 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number"));
1998 }
1999 _ => panic!("Expected inline schema for {}", schema_type),
2000 }
2001 }
2002
2003 for schema_type in ["boolean", "bool"] {
2005 let schema = schema_type_to_openapi_schema(schema_type);
2006 match schema {
2007 rustapi_openapi::SchemaRef::Inline(v) => {
2008 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean"));
2009 }
2010 _ => panic!("Expected inline schema for {}", schema_type),
2011 }
2012 }
2013
2014 let string_schema = schema_type_to_openapi_schema("string");
2016 match string_schema {
2017 rustapi_openapi::SchemaRef::Inline(v) => {
2018 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2019 }
2020 _ => panic!("Expected inline schema for string"),
2021 }
2022 }
2023
2024 proptest! {
2031 #![proptest_config(ProptestConfig::with_cases(100))]
2032
2033 #[test]
2038 fn prop_nested_routes_in_openapi_spec(
2039 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2041 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2043 has_param in any::<bool>(),
2044 ) {
2045 async fn handler() -> &'static str { "handler" }
2046
2047 let prefix = format!("/{}", prefix_segments.join("/"));
2049
2050 let mut route_path = format!("/{}", route_segments.join("/"));
2052 if has_param {
2053 route_path.push_str("/{id}");
2054 }
2055
2056 let nested_router = Router::new().route(&route_path, get(handler));
2058 let app = RustApi::new().nest(&prefix, nested_router);
2059
2060 let expected_openapi_path = format!("{}{}", prefix, route_path);
2062
2063 let spec = app.openapi_spec();
2065
2066 prop_assert!(
2068 spec.paths.contains_key(&expected_openapi_path),
2069 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2070 expected_openapi_path,
2071 spec.paths.keys().collect::<Vec<_>>()
2072 );
2073
2074 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2076 prop_assert!(
2077 path_item.get.is_some(),
2078 "GET operation should exist for path '{}'",
2079 expected_openapi_path
2080 );
2081 }
2082
2083 #[test]
2088 fn prop_multiple_methods_preserved_in_openapi(
2089 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2090 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2091 ) {
2092 async fn get_handler() -> &'static str { "get" }
2093 async fn post_handler() -> &'static str { "post" }
2094
2095 let prefix = format!("/{}", prefix_segments.join("/"));
2097 let route_path = format!("/{}", route_segments.join("/"));
2098
2099 let get_route_path = format!("{}/get", route_path);
2102 let post_route_path = format!("{}/post", route_path);
2103 let nested_router = Router::new()
2104 .route(&get_route_path, get(get_handler))
2105 .route(&post_route_path, post(post_handler));
2106 let app = RustApi::new().nest(&prefix, nested_router);
2107
2108 let expected_get_path = format!("{}{}", prefix, get_route_path);
2110 let expected_post_path = format!("{}{}", prefix, post_route_path);
2111
2112 let spec = app.openapi_spec();
2114
2115 prop_assert!(
2117 spec.paths.contains_key(&expected_get_path),
2118 "Expected OpenAPI path '{}' not found",
2119 expected_get_path
2120 );
2121 prop_assert!(
2122 spec.paths.contains_key(&expected_post_path),
2123 "Expected OpenAPI path '{}' not found",
2124 expected_post_path
2125 );
2126
2127 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
2129 prop_assert!(
2130 get_path_item.get.is_some(),
2131 "GET operation should exist for path '{}'",
2132 expected_get_path
2133 );
2134
2135 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
2137 prop_assert!(
2138 post_path_item.post.is_some(),
2139 "POST operation should exist for path '{}'",
2140 expected_post_path
2141 );
2142 }
2143
2144 #[test]
2149 fn prop_path_params_in_openapi_after_nesting(
2150 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2151 param_name in "[a-z][a-z0-9]{0,5}",
2152 ) {
2153 async fn handler() -> &'static str { "handler" }
2154
2155 let prefix = format!("/{}", prefix_segments.join("/"));
2157 let route_path = format!("/{{{}}}", param_name);
2158
2159 let nested_router = Router::new().route(&route_path, get(handler));
2161 let app = RustApi::new().nest(&prefix, nested_router);
2162
2163 let expected_openapi_path = format!("{}{}", prefix, route_path);
2165
2166 let spec = app.openapi_spec();
2168
2169 prop_assert!(
2171 spec.paths.contains_key(&expected_openapi_path),
2172 "Expected OpenAPI path '{}' not found",
2173 expected_openapi_path
2174 );
2175
2176 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2178 let get_op = path_item.get.as_ref().unwrap();
2179
2180 prop_assert!(
2181 !get_op.parameters.is_empty(),
2182 "Operation should have parameters for path '{}'",
2183 expected_openapi_path
2184 );
2185
2186 let params = &get_op.parameters;
2187 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
2188 prop_assert!(
2189 has_param,
2190 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
2191 param_name,
2192 params.iter().map(|p| &p.name).collect::<Vec<_>>()
2193 );
2194 }
2195 }
2196
2197 proptest! {
2205 #![proptest_config(ProptestConfig::with_cases(100))]
2206
2207 #[test]
2212 fn prop_rustapi_nest_delegates_to_router_nest(
2213 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2214 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2215 has_param in any::<bool>(),
2216 ) {
2217 async fn handler() -> &'static str { "handler" }
2218
2219 let prefix = format!("/{}", prefix_segments.join("/"));
2221
2222 let mut route_path = format!("/{}", route_segments.join("/"));
2224 if has_param {
2225 route_path.push_str("/{id}");
2226 }
2227
2228 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2230 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2231
2232 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2234 let rustapi_router = rustapi_app.into_router();
2235
2236 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2238
2239 let rustapi_routes = rustapi_router.registered_routes();
2241 let router_routes = router_app.registered_routes();
2242
2243 prop_assert_eq!(
2244 rustapi_routes.len(),
2245 router_routes.len(),
2246 "RustApi and Router should have same number of routes"
2247 );
2248
2249 for (path, info) in router_routes {
2251 prop_assert!(
2252 rustapi_routes.contains_key(path),
2253 "Route '{}' from Router should exist in RustApi routes",
2254 path
2255 );
2256
2257 let rustapi_info = rustapi_routes.get(path).unwrap();
2258 prop_assert_eq!(
2259 &info.path, &rustapi_info.path,
2260 "Display paths should match for route '{}'",
2261 path
2262 );
2263 prop_assert_eq!(
2264 info.methods.len(), rustapi_info.methods.len(),
2265 "Method count should match for route '{}'",
2266 path
2267 );
2268 }
2269 }
2270
2271 #[test]
2276 fn prop_rustapi_nest_includes_routes_in_openapi(
2277 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2278 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2279 has_param in any::<bool>(),
2280 ) {
2281 async fn handler() -> &'static str { "handler" }
2282
2283 let prefix = format!("/{}", prefix_segments.join("/"));
2285
2286 let mut route_path = format!("/{}", route_segments.join("/"));
2288 if has_param {
2289 route_path.push_str("/{id}");
2290 }
2291
2292 let nested_router = Router::new().route(&route_path, get(handler));
2294 let app = RustApi::new().nest(&prefix, nested_router);
2295
2296 let expected_openapi_path = format!("{}{}", prefix, route_path);
2298
2299 let spec = app.openapi_spec();
2301
2302 prop_assert!(
2304 spec.paths.contains_key(&expected_openapi_path),
2305 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2306 expected_openapi_path,
2307 spec.paths.keys().collect::<Vec<_>>()
2308 );
2309
2310 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2312 prop_assert!(
2313 path_item.get.is_some(),
2314 "GET operation should exist for path '{}'",
2315 expected_openapi_path
2316 );
2317 }
2318
2319 #[test]
2324 fn prop_rustapi_nest_route_matching_identical(
2325 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2326 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2327 param_value in "[a-z0-9]{1,10}",
2328 ) {
2329 use crate::router::RouteMatch;
2330
2331 async fn handler() -> &'static str { "handler" }
2332
2333 let prefix = format!("/{}", prefix_segments.join("/"));
2335 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
2336
2337 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2339 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2340
2341 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2343 let rustapi_router = rustapi_app.into_router();
2344 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2345
2346 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
2348
2349 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
2351 let router_match = router_app.match_route(&full_path, &Method::GET);
2352
2353 match (rustapi_match, router_match) {
2355 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
2356 prop_assert_eq!(
2357 rustapi_params.len(),
2358 router_params.len(),
2359 "Parameter count should match"
2360 );
2361 for (key, value) in &router_params {
2362 prop_assert!(
2363 rustapi_params.contains_key(key),
2364 "RustApi should have parameter '{}'",
2365 key
2366 );
2367 prop_assert_eq!(
2368 rustapi_params.get(key).unwrap(),
2369 value,
2370 "Parameter '{}' value should match",
2371 key
2372 );
2373 }
2374 }
2375 (rustapi_result, router_result) => {
2376 prop_assert!(
2377 false,
2378 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
2379 match rustapi_result {
2380 RouteMatch::Found { .. } => "Found",
2381 RouteMatch::NotFound => "NotFound",
2382 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2383 },
2384 match router_result {
2385 RouteMatch::Found { .. } => "Found",
2386 RouteMatch::NotFound => "NotFound",
2387 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2388 }
2389 );
2390 }
2391 }
2392 }
2393 }
2394
2395 #[test]
2397 fn test_openapi_operations_propagated_during_nesting() {
2398 async fn list_users() -> &'static str {
2399 "list users"
2400 }
2401 async fn get_user() -> &'static str {
2402 "get user"
2403 }
2404 async fn create_user() -> &'static str {
2405 "create user"
2406 }
2407
2408 let users_router = Router::new()
2411 .route("/", get(list_users))
2412 .route("/create", post(create_user))
2413 .route("/{id}", get(get_user));
2414
2415 let app = RustApi::new().nest("/api/v1/users", users_router);
2417
2418 let spec = app.openapi_spec();
2419
2420 assert!(
2422 spec.paths.contains_key("/api/v1/users"),
2423 "Should have /api/v1/users path"
2424 );
2425 let users_path = spec.paths.get("/api/v1/users").unwrap();
2426 assert!(users_path.get.is_some(), "Should have GET operation");
2427
2428 assert!(
2430 spec.paths.contains_key("/api/v1/users/create"),
2431 "Should have /api/v1/users/create path"
2432 );
2433 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
2434 assert!(create_path.post.is_some(), "Should have POST operation");
2435
2436 assert!(
2438 spec.paths.contains_key("/api/v1/users/{id}"),
2439 "Should have /api/v1/users/{{id}} path"
2440 );
2441 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
2442 assert!(
2443 user_path.get.is_some(),
2444 "Should have GET operation for user by id"
2445 );
2446
2447 let get_user_op = user_path.get.as_ref().unwrap();
2449 assert!(!get_user_op.parameters.is_empty(), "Should have parameters");
2450 let params = &get_user_op.parameters;
2451 assert!(
2452 params
2453 .iter()
2454 .any(|p| p.name == "id" && p.location == "path"),
2455 "Should have 'id' path parameter"
2456 );
2457 }
2458
2459 #[test]
2461 fn test_openapi_spec_empty_without_routes() {
2462 let app = RustApi::new();
2463 let spec = app.openapi_spec();
2464
2465 assert!(
2467 spec.paths.is_empty(),
2468 "OpenAPI spec should have no paths without routes"
2469 );
2470 }
2471
2472 #[test]
2477 fn test_rustapi_nest_delegates_to_router_nest() {
2478 use crate::router::RouteMatch;
2479
2480 async fn list_users() -> &'static str {
2481 "list users"
2482 }
2483 async fn get_user() -> &'static str {
2484 "get user"
2485 }
2486 async fn create_user() -> &'static str {
2487 "create user"
2488 }
2489
2490 let users_router = Router::new()
2492 .route("/", get(list_users))
2493 .route("/create", post(create_user))
2494 .route("/{id}", get(get_user));
2495
2496 let app = RustApi::new().nest("/api/v1/users", users_router);
2498 let router = app.into_router();
2499
2500 let routes = router.registered_routes();
2502 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
2503
2504 assert!(
2506 routes.contains_key("/api/v1/users"),
2507 "Should have /api/v1/users route"
2508 );
2509 assert!(
2510 routes.contains_key("/api/v1/users/create"),
2511 "Should have /api/v1/users/create route"
2512 );
2513 assert!(
2514 routes.contains_key("/api/v1/users/:id"),
2515 "Should have /api/v1/users/:id route"
2516 );
2517
2518 match router.match_route("/api/v1/users", &Method::GET) {
2520 RouteMatch::Found { params, .. } => {
2521 assert!(params.is_empty(), "Root route should have no params");
2522 }
2523 _ => panic!("GET /api/v1/users should be found"),
2524 }
2525
2526 match router.match_route("/api/v1/users/create", &Method::POST) {
2527 RouteMatch::Found { params, .. } => {
2528 assert!(params.is_empty(), "Create route should have no params");
2529 }
2530 _ => panic!("POST /api/v1/users/create should be found"),
2531 }
2532
2533 match router.match_route("/api/v1/users/123", &Method::GET) {
2534 RouteMatch::Found { params, .. } => {
2535 assert_eq!(
2536 params.get("id"),
2537 Some(&"123".to_string()),
2538 "Should extract id param"
2539 );
2540 }
2541 _ => panic!("GET /api/v1/users/123 should be found"),
2542 }
2543
2544 match router.match_route("/api/v1/users", &Method::DELETE) {
2546 RouteMatch::MethodNotAllowed { allowed } => {
2547 assert!(allowed.contains(&Method::GET), "Should allow GET");
2548 }
2549 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
2550 }
2551 }
2552
2553 #[test]
2558 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
2559 async fn list_items() -> &'static str {
2560 "list items"
2561 }
2562 async fn get_item() -> &'static str {
2563 "get item"
2564 }
2565
2566 let items_router = Router::new()
2568 .route("/", get(list_items))
2569 .route("/{item_id}", get(get_item));
2570
2571 let app = RustApi::new().nest("/api/items", items_router);
2573
2574 let spec = app.openapi_spec();
2576
2577 assert!(
2579 spec.paths.contains_key("/api/items"),
2580 "Should have /api/items in OpenAPI"
2581 );
2582 assert!(
2583 spec.paths.contains_key("/api/items/{item_id}"),
2584 "Should have /api/items/{{item_id}} in OpenAPI"
2585 );
2586
2587 let list_path = spec.paths.get("/api/items").unwrap();
2589 assert!(
2590 list_path.get.is_some(),
2591 "Should have GET operation for /api/items"
2592 );
2593
2594 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
2595 assert!(
2596 get_path.get.is_some(),
2597 "Should have GET operation for /api/items/{{item_id}}"
2598 );
2599
2600 let get_op = get_path.get.as_ref().unwrap();
2602 assert!(!get_op.parameters.is_empty(), "Should have parameters");
2603 let params = &get_op.parameters;
2604 assert!(
2605 params
2606 .iter()
2607 .any(|p| p.name == "item_id" && p.location == "path"),
2608 "Should have 'item_id' path parameter"
2609 );
2610 }
2611}