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 method_enum = match route.method {
495 "GET" => http::Method::GET,
496 "POST" => http::Method::POST,
497 "PUT" => http::Method::PUT,
498 "DELETE" => http::Method::DELETE,
499 "PATCH" => http::Method::PATCH,
500 _ => http::Method::GET,
501 };
502
503 let path = if route.path.starts_with('/') {
504 route.path.to_string()
505 } else {
506 format!("/{}", route.path)
507 };
508
509 let entry = by_path.entry(path).or_default();
510 entry.insert_boxed_with_operation(method_enum, route.handler, route.operation);
511 }
512
513 #[cfg(feature = "tracing")]
514 let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
515 #[cfg(feature = "tracing")]
516 let path_count = by_path.len();
517
518 for (path, method_router) in by_path {
519 self = self.route(&path, method_router);
520 }
521
522 crate::trace_info!(
523 paths = path_count,
524 routes = route_count,
525 "Auto-registered routes"
526 );
527
528 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
530
531 self
532 }
533
534 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
545 for register_components in &method_router.component_registrars {
546 register_components(&mut self.openapi_spec);
547 }
548
549 for (method, op) in &method_router.operations {
551 let mut op = op.clone();
552 add_path_params_to_operation(path, &mut op, &BTreeMap::new());
553 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
554 }
555
556 self.router = self.router.route(path, method_router);
557 self
558 }
559
560 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
562 self.route(P::PATH, method_router)
563 }
564
565 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
569 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
570 self.route(path, method_router)
571 }
572
573 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
591 let method_enum = match route.method {
592 "GET" => http::Method::GET,
593 "POST" => http::Method::POST,
594 "PUT" => http::Method::PUT,
595 "DELETE" => http::Method::DELETE,
596 "PATCH" => http::Method::PATCH,
597 _ => http::Method::GET,
598 };
599
600 (route.component_registrar)(&mut self.openapi_spec);
601
602 let mut op = route.operation;
604 add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
605 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
606
607 self.route_with_method(route.path, method_enum, route.handler)
608 }
609
610 fn route_with_method(
612 self,
613 path: &str,
614 method: http::Method,
615 handler: crate::handler::BoxedHandler,
616 ) -> Self {
617 use crate::router::MethodRouter;
618 let path = if !path.starts_with('/') {
627 format!("/{}", path)
628 } else {
629 path.to_string()
630 };
631
632 let mut handlers = std::collections::HashMap::new();
641 handlers.insert(method, handler);
642
643 let method_router = MethodRouter::from_boxed(handlers);
644 self.route(&path, method_router)
645 }
646
647 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
663 let normalized_prefix = normalize_prefix_for_openapi(prefix);
665
666 for (matchit_path, method_router) in router.method_routers() {
669 for register_components in &method_router.component_registrars {
670 register_components(&mut self.openapi_spec);
671 }
672
673 let display_path = router
675 .registered_routes()
676 .get(matchit_path)
677 .map(|info| info.path.clone())
678 .unwrap_or_else(|| matchit_path.clone());
679
680 let prefixed_path = if display_path == "/" {
682 normalized_prefix.clone()
683 } else {
684 format!("{}{}", normalized_prefix, display_path)
685 };
686
687 for (method, op) in &method_router.operations {
689 let mut op = op.clone();
690 add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
691 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
692 }
693 }
694
695 self.router = self.router.nest(prefix, router);
697 self
698 }
699
700 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
729 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
730 }
731
732 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
749 use crate::router::MethodRouter;
750 use std::collections::HashMap;
751
752 let prefix = config.prefix.clone();
753 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
754
755 let handler: crate::handler::BoxedHandler =
757 std::sync::Arc::new(move |req: crate::Request| {
758 let config = config.clone();
759 let path = req.uri().path().to_string();
760
761 Box::pin(async move {
762 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
763
764 match crate::static_files::StaticFile::serve(relative_path, &config).await {
765 Ok(response) => response,
766 Err(err) => err.into_response(),
767 }
768 })
769 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
770 });
771
772 let mut handlers = HashMap::new();
773 handlers.insert(http::Method::GET, handler);
774 let method_router = MethodRouter::from_boxed(handlers);
775
776 self.route(&catch_all_path, method_router)
777 }
778
779 #[cfg(feature = "compression")]
796 pub fn compression(self) -> Self {
797 self.layer(crate::middleware::CompressionLayer::new())
798 }
799
800 #[cfg(feature = "compression")]
816 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
817 self.layer(crate::middleware::CompressionLayer::with_config(config))
818 }
819
820 #[cfg(feature = "swagger-ui")]
844 pub fn docs(self, path: &str) -> Self {
845 let title = self.openapi_spec.info.title.clone();
846 let version = self.openapi_spec.info.version.clone();
847 let description = self.openapi_spec.info.description.clone();
848
849 self.docs_with_info(path, &title, &version, description.as_deref())
850 }
851
852 #[cfg(feature = "swagger-ui")]
861 pub fn docs_with_info(
862 mut self,
863 path: &str,
864 title: &str,
865 version: &str,
866 description: Option<&str>,
867 ) -> Self {
868 use crate::router::get;
869 self.openapi_spec.info.title = title.to_string();
871 self.openapi_spec.info.version = version.to_string();
872 if let Some(desc) = description {
873 self.openapi_spec.info.description = Some(desc.to_string());
874 }
875
876 let path = path.trim_end_matches('/');
877 let openapi_path = format!("{}/openapi.json", path);
878
879 let spec_value = self.openapi_spec.to_json();
881 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
882 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
884 "{}".to_string()
885 });
886 let openapi_url = openapi_path.clone();
887
888 let spec_handler = move || {
890 let json = spec_json.clone();
891 async move {
892 http::Response::builder()
893 .status(http::StatusCode::OK)
894 .header(http::header::CONTENT_TYPE, "application/json")
895 .body(crate::response::Body::from(json))
896 .unwrap_or_else(|e| {
897 tracing::error!("Failed to build response: {}", e);
898 http::Response::builder()
899 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
900 .body(crate::response::Body::from("Internal Server Error"))
901 .unwrap()
902 })
903 }
904 };
905
906 let docs_handler = move || {
908 let url = openapi_url.clone();
909 async move {
910 let response = rustapi_openapi::swagger_ui_html(&url);
911 response.map(crate::response::Body::Full)
912 }
913 };
914
915 self.route(&openapi_path, get(spec_handler))
916 .route(path, get(docs_handler))
917 }
918
919 #[cfg(feature = "swagger-ui")]
935 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
936 let title = self.openapi_spec.info.title.clone();
937 let version = self.openapi_spec.info.version.clone();
938 let description = self.openapi_spec.info.description.clone();
939
940 self.docs_with_auth_and_info(
941 path,
942 username,
943 password,
944 &title,
945 &version,
946 description.as_deref(),
947 )
948 }
949
950 #[cfg(feature = "swagger-ui")]
966 pub fn docs_with_auth_and_info(
967 mut self,
968 path: &str,
969 username: &str,
970 password: &str,
971 title: &str,
972 version: &str,
973 description: Option<&str>,
974 ) -> Self {
975 use crate::router::MethodRouter;
976 use base64::{engine::general_purpose::STANDARD, Engine};
977 use std::collections::HashMap;
978
979 self.openapi_spec.info.title = title.to_string();
981 self.openapi_spec.info.version = version.to_string();
982 if let Some(desc) = description {
983 self.openapi_spec.info.description = Some(desc.to_string());
984 }
985
986 let path = path.trim_end_matches('/');
987 let openapi_path = format!("{}/openapi.json", path);
988
989 let credentials = format!("{}:{}", username, password);
991 let encoded = STANDARD.encode(credentials.as_bytes());
992 let expected_auth = format!("Basic {}", encoded);
993
994 let spec_value = self.openapi_spec.to_json();
996 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
997 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
998 "{}".to_string()
999 });
1000 let openapi_url = openapi_path.clone();
1001 let expected_auth_spec = expected_auth.clone();
1002 let expected_auth_docs = expected_auth;
1003
1004 let spec_handler: crate::handler::BoxedHandler =
1006 std::sync::Arc::new(move |req: crate::Request| {
1007 let json = spec_json.clone();
1008 let expected = expected_auth_spec.clone();
1009 Box::pin(async move {
1010 if !check_basic_auth(&req, &expected) {
1011 return unauthorized_response();
1012 }
1013 http::Response::builder()
1014 .status(http::StatusCode::OK)
1015 .header(http::header::CONTENT_TYPE, "application/json")
1016 .body(crate::response::Body::from(json))
1017 .unwrap_or_else(|e| {
1018 tracing::error!("Failed to build response: {}", e);
1019 http::Response::builder()
1020 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1021 .body(crate::response::Body::from("Internal Server Error"))
1022 .unwrap()
1023 })
1024 })
1025 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1026 });
1027
1028 let docs_handler: crate::handler::BoxedHandler =
1030 std::sync::Arc::new(move |req: crate::Request| {
1031 let url = openapi_url.clone();
1032 let expected = expected_auth_docs.clone();
1033 Box::pin(async move {
1034 if !check_basic_auth(&req, &expected) {
1035 return unauthorized_response();
1036 }
1037 let response = rustapi_openapi::swagger_ui_html(&url);
1038 response.map(crate::response::Body::Full)
1039 })
1040 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1041 });
1042
1043 let mut spec_handlers = HashMap::new();
1045 spec_handlers.insert(http::Method::GET, spec_handler);
1046 let spec_router = MethodRouter::from_boxed(spec_handlers);
1047
1048 let mut docs_handlers = HashMap::new();
1049 docs_handlers.insert(http::Method::GET, docs_handler);
1050 let docs_router = MethodRouter::from_boxed(docs_handlers);
1051
1052 self.route(&openapi_path, spec_router)
1053 .route(path, docs_router)
1054 }
1055
1056 pub fn status_page(self) -> Self {
1058 self.status_page_with_config(crate::status::StatusConfig::default())
1059 }
1060
1061 pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self {
1063 self.status_config = Some(config);
1064 self
1065 }
1066
1067 pub fn health_endpoints(mut self) -> Self {
1072 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1073 if self.health_check.is_none() {
1074 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1075 }
1076 self
1077 }
1078
1079 pub fn health_endpoints_with_config(
1081 mut self,
1082 config: crate::health::HealthEndpointConfig,
1083 ) -> Self {
1084 self.health_endpoint_config = Some(config);
1085 if self.health_check.is_none() {
1086 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1087 }
1088 self
1089 }
1090
1091 pub fn with_health_check(mut self, health_check: crate::health::HealthCheck) -> Self {
1096 self.health_check = Some(health_check);
1097 if self.health_endpoint_config.is_none() {
1098 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1099 }
1100 self
1101 }
1102
1103 pub fn production_defaults(self, service_name: impl Into<String>) -> Self {
1110 self.production_defaults_with_config(ProductionDefaultsConfig::new(service_name))
1111 }
1112
1113 pub fn production_defaults_with_config(mut self, config: ProductionDefaultsConfig) -> Self {
1115 if config.enable_request_id {
1116 self = self.layer(crate::middleware::RequestIdLayer::new());
1117 }
1118
1119 if config.enable_tracing {
1120 let mut tracing_layer =
1121 crate::middleware::TracingLayer::with_level(config.tracing_level)
1122 .with_field("service", config.service_name.clone())
1123 .with_field("environment", crate::error::get_environment().to_string());
1124
1125 if let Some(version) = &config.version {
1126 tracing_layer = tracing_layer.with_field("version", version.clone());
1127 }
1128
1129 self = self.layer(tracing_layer);
1130 }
1131
1132 if config.enable_health_endpoints {
1133 if self.health_check.is_none() {
1134 let mut builder = crate::health::HealthCheckBuilder::default();
1135 if let Some(version) = &config.version {
1136 builder = builder.version(version.clone());
1137 }
1138 self.health_check = Some(builder.build());
1139 }
1140
1141 if self.health_endpoint_config.is_none() {
1142 self.health_endpoint_config =
1143 Some(config.health_endpoint_config.unwrap_or_default());
1144 }
1145 }
1146
1147 self
1148 }
1149
1150 fn print_hot_reload_banner(&self, addr: &str) {
1152 if !self.hot_reload {
1153 return;
1154 }
1155
1156 std::env::set_var("RUSTAPI_HOT_RELOAD", "1");
1158
1159 let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD")
1160 .map(|v| v == "1")
1161 .unwrap_or(false);
1162
1163 tracing::info!("🔄 Hot-reload mode enabled");
1164
1165 if is_under_watcher {
1166 tracing::info!(" File watcher active — changes will trigger rebuild + restart");
1167 } else {
1168 tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload");
1169 }
1170
1171 tracing::info!(" Listening on http://{addr}");
1172 }
1173
1174 fn apply_health_endpoints(&mut self) {
1176 if let Some(config) = &self.health_endpoint_config {
1177 use crate::router::get;
1178
1179 let health_check = self
1180 .health_check
1181 .clone()
1182 .unwrap_or_else(|| crate::health::HealthCheckBuilder::default().build());
1183
1184 let health_path = config.health_path.clone();
1185 let readiness_path = config.readiness_path.clone();
1186 let liveness_path = config.liveness_path.clone();
1187
1188 let health_handler = {
1189 let health_check = health_check.clone();
1190 move || {
1191 let health_check = health_check.clone();
1192 async move { crate::health::health_response(health_check).await }
1193 }
1194 };
1195
1196 let readiness_handler = {
1197 let health_check = health_check.clone();
1198 move || {
1199 let health_check = health_check.clone();
1200 async move { crate::health::readiness_response(health_check).await }
1201 }
1202 };
1203
1204 let liveness_handler = || async { crate::health::liveness_response().await };
1205
1206 let router = std::mem::take(&mut self.router);
1207 self.router = router
1208 .route(&health_path, get(health_handler))
1209 .route(&readiness_path, get(readiness_handler))
1210 .route(&liveness_path, get(liveness_handler));
1211 }
1212 }
1213
1214 fn apply_status_page(&mut self) {
1215 if let Some(config) = &self.status_config {
1216 let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new());
1217
1218 self.layers
1220 .push(Box::new(crate::status::StatusLayer::new(monitor.clone())));
1221
1222 use crate::router::MethodRouter;
1224 use std::collections::HashMap;
1225
1226 let monitor = monitor.clone();
1227 let config = config.clone();
1228 let path = config.path.clone(); let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| {
1231 let monitor = monitor.clone();
1232 let config = config.clone();
1233 Box::pin(async move {
1234 crate::status::status_handler(monitor, config)
1235 .await
1236 .into_response()
1237 })
1238 });
1239
1240 let mut handlers = HashMap::new();
1241 handlers.insert(http::Method::GET, handler);
1242 let method_router = MethodRouter::from_boxed(handlers);
1243
1244 let router = std::mem::take(&mut self.router);
1246 self.router = router.route(&path, method_router);
1247 }
1248 }
1249
1250 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1261 self.print_hot_reload_banner(addr);
1263
1264 self.apply_health_endpoints();
1266
1267 self.apply_status_page();
1269
1270 if let Some(limit) = self.body_limit {
1272 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1274 }
1275
1276 for hook in self.lifecycle_hooks.on_start {
1278 hook().await;
1279 }
1280
1281 let server = Server::new(self.router, self.layers, self.interceptors);
1282 server.run(addr).await
1283 }
1284
1285 pub async fn run_with_shutdown<F>(
1287 mut self,
1288 addr: impl AsRef<str>,
1289 signal: F,
1290 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1291 where
1292 F: std::future::Future<Output = ()> + Send + 'static,
1293 {
1294 self.print_hot_reload_banner(addr.as_ref());
1296
1297 self.apply_health_endpoints();
1299
1300 self.apply_status_page();
1302
1303 if let Some(limit) = self.body_limit {
1304 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1305 }
1306
1307 for hook in self.lifecycle_hooks.on_start {
1309 hook().await;
1310 }
1311
1312 let shutdown_hooks = self.lifecycle_hooks.on_shutdown;
1314 let wrapped_signal = async move {
1315 signal.await;
1316 for hook in shutdown_hooks {
1318 hook().await;
1319 }
1320 };
1321
1322 let server = Server::new(self.router, self.layers, self.interceptors);
1323 server
1324 .run_with_shutdown(addr.as_ref(), wrapped_signal)
1325 .await
1326 }
1327
1328 pub fn into_router(self) -> Router {
1330 self.router
1331 }
1332
1333 pub fn layers(&self) -> &LayerStack {
1335 &self.layers
1336 }
1337
1338 pub fn interceptors(&self) -> &InterceptorChain {
1340 &self.interceptors
1341 }
1342
1343 #[cfg(feature = "http3")]
1357 pub async fn run_http3(
1358 mut self,
1359 config: crate::http3::Http3Config,
1360 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1361 use std::sync::Arc;
1362
1363 self.apply_health_endpoints();
1365
1366 self.apply_status_page();
1368
1369 if let Some(limit) = self.body_limit {
1371 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1372 }
1373
1374 let server = crate::http3::Http3Server::new(
1375 &config,
1376 Arc::new(self.router),
1377 Arc::new(self.layers),
1378 Arc::new(self.interceptors),
1379 )
1380 .await?;
1381
1382 server.run().await
1383 }
1384
1385 #[cfg(feature = "http3-dev")]
1399 pub async fn run_http3_dev(
1400 mut self,
1401 addr: &str,
1402 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1403 use std::sync::Arc;
1404
1405 self.apply_health_endpoints();
1407
1408 self.apply_status_page();
1410
1411 if let Some(limit) = self.body_limit {
1413 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1414 }
1415
1416 let server = crate::http3::Http3Server::new_with_self_signed(
1417 addr,
1418 Arc::new(self.router),
1419 Arc::new(self.layers),
1420 Arc::new(self.interceptors),
1421 )
1422 .await?;
1423
1424 server.run().await
1425 }
1426
1427 #[cfg(feature = "http3")]
1438 pub fn with_http3(mut self, cert_path: impl Into<String>, key_path: impl Into<String>) -> Self {
1439 self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path));
1440 self
1441 }
1442
1443 #[cfg(feature = "http3")]
1458 pub async fn run_dual_stack(
1459 mut self,
1460 http_addr: &str,
1461 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1462 use std::sync::Arc;
1463
1464 let mut config = self
1465 .http3_config
1466 .take()
1467 .ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
1468
1469 let http_socket: std::net::SocketAddr = http_addr.parse()?;
1470 config.bind_addr = if http_socket.ip().is_ipv6() {
1471 format!("[{}]", http_socket.ip())
1472 } else {
1473 http_socket.ip().to_string()
1474 };
1475 config.port = http_socket.port();
1476 let http_addr = http_socket.to_string();
1477
1478 self.apply_health_endpoints();
1480
1481 self.apply_status_page();
1483
1484 if let Some(limit) = self.body_limit {
1486 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1487 }
1488
1489 let router = Arc::new(self.router);
1490 let layers = Arc::new(self.layers);
1491 let interceptors = Arc::new(self.interceptors);
1492
1493 let http1_server =
1494 Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1495 let http3_server =
1496 crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1497
1498 tracing::info!(
1499 http1_addr = %http_addr,
1500 http3_addr = %config.socket_addr(),
1501 "Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1502 );
1503
1504 tokio::try_join!(
1505 http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1506 http3_server.run_with_shutdown(std::future::pending::<()>()),
1507 )?;
1508
1509 Ok(())
1510 }
1511}
1512
1513fn add_path_params_to_operation(
1514 path: &str,
1515 op: &mut rustapi_openapi::Operation,
1516 param_schemas: &BTreeMap<String, String>,
1517) {
1518 let mut params: Vec<String> = Vec::new();
1519 let mut in_brace = false;
1520 let mut current = String::new();
1521
1522 for ch in path.chars() {
1523 match ch {
1524 '{' => {
1525 in_brace = true;
1526 current.clear();
1527 }
1528 '}' => {
1529 if in_brace {
1530 in_brace = false;
1531 if !current.is_empty() {
1532 params.push(current.clone());
1533 }
1534 }
1535 }
1536 _ => {
1537 if in_brace {
1538 current.push(ch);
1539 }
1540 }
1541 }
1542 }
1543
1544 if params.is_empty() {
1545 return;
1546 }
1547
1548 let op_params = &mut op.parameters;
1549
1550 for name in params {
1551 let already = op_params
1552 .iter()
1553 .any(|p| p.location == "path" && p.name == name);
1554 if already {
1555 continue;
1556 }
1557
1558 let schema = if let Some(schema_type) = param_schemas.get(&name) {
1560 schema_type_to_openapi_schema(schema_type)
1561 } else {
1562 infer_path_param_schema(&name)
1563 };
1564
1565 op_params.push(rustapi_openapi::Parameter {
1566 name,
1567 location: "path".to_string(),
1568 required: true,
1569 description: None,
1570 deprecated: None,
1571 schema: Some(schema),
1572 });
1573 }
1574}
1575
1576fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef {
1578 match schema_type.to_lowercase().as_str() {
1579 "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1580 "type": "string",
1581 "format": "uuid"
1582 })),
1583 "integer" | "int" | "int64" | "i64" => {
1584 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1585 "type": "integer",
1586 "format": "int64"
1587 }))
1588 }
1589 "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1590 "type": "integer",
1591 "format": "int32"
1592 })),
1593 "number" | "float" | "f64" | "f32" => {
1594 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1595 "type": "number"
1596 }))
1597 }
1598 "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1599 "type": "boolean"
1600 })),
1601 _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1602 "type": "string"
1603 })),
1604 }
1605}
1606
1607fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
1616 let lower = name.to_lowercase();
1617
1618 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
1620
1621 if is_uuid {
1622 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1623 "type": "string",
1624 "format": "uuid"
1625 }));
1626 }
1627
1628 let is_integer = lower == "page"
1631 || lower == "limit"
1632 || lower == "offset"
1633 || lower == "count"
1634 || lower.ends_with("_count")
1635 || lower.ends_with("_num")
1636 || lower == "year"
1637 || lower == "month"
1638 || lower == "day"
1639 || lower == "index"
1640 || lower == "position";
1641
1642 if is_integer {
1643 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1644 "type": "integer",
1645 "format": "int64"
1646 }))
1647 } else {
1648 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
1649 }
1650}
1651
1652fn normalize_prefix_for_openapi(prefix: &str) -> String {
1659 if prefix.is_empty() {
1661 return "/".to_string();
1662 }
1663
1664 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
1666
1667 if segments.is_empty() {
1669 return "/".to_string();
1670 }
1671
1672 let mut result = String::with_capacity(prefix.len() + 1);
1674 for segment in segments {
1675 result.push('/');
1676 result.push_str(segment);
1677 }
1678
1679 result
1680}
1681
1682impl Default for RustApi {
1683 fn default() -> Self {
1684 Self::new()
1685 }
1686}
1687
1688#[cfg(feature = "swagger-ui")]
1690fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1691 req.headers()
1692 .get(http::header::AUTHORIZATION)
1693 .and_then(|v| v.to_str().ok())
1694 .map(|auth| auth == expected)
1695 .unwrap_or(false)
1696}
1697
1698#[cfg(feature = "swagger-ui")]
1700fn unauthorized_response() -> crate::Response {
1701 http::Response::builder()
1702 .status(http::StatusCode::UNAUTHORIZED)
1703 .header(
1704 http::header::WWW_AUTHENTICATE,
1705 "Basic realm=\"API Documentation\"",
1706 )
1707 .header(http::header::CONTENT_TYPE, "text/plain")
1708 .body(crate::response::Body::from("Unauthorized"))
1709 .unwrap()
1710}
1711
1712pub struct RustApiConfig {
1714 docs_path: Option<String>,
1715 docs_enabled: bool,
1716 api_title: String,
1717 api_version: String,
1718 api_description: Option<String>,
1719 body_limit: Option<usize>,
1720 layers: LayerStack,
1721}
1722
1723impl Default for RustApiConfig {
1724 fn default() -> Self {
1725 Self::new()
1726 }
1727}
1728
1729impl RustApiConfig {
1730 pub fn new() -> Self {
1731 Self {
1732 docs_path: Some("/docs".to_string()),
1733 docs_enabled: true,
1734 api_title: "RustAPI".to_string(),
1735 api_version: "1.0.0".to_string(),
1736 api_description: None,
1737 body_limit: None,
1738 layers: LayerStack::new(),
1739 }
1740 }
1741
1742 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
1744 self.docs_path = Some(path.into());
1745 self
1746 }
1747
1748 pub fn docs_enabled(mut self, enabled: bool) -> Self {
1750 self.docs_enabled = enabled;
1751 self
1752 }
1753
1754 pub fn openapi_info(
1756 mut self,
1757 title: impl Into<String>,
1758 version: impl Into<String>,
1759 description: Option<impl Into<String>>,
1760 ) -> Self {
1761 self.api_title = title.into();
1762 self.api_version = version.into();
1763 self.api_description = description.map(|d| d.into());
1764 self
1765 }
1766
1767 pub fn body_limit(mut self, limit: usize) -> Self {
1769 self.body_limit = Some(limit);
1770 self
1771 }
1772
1773 pub fn layer<L>(mut self, layer: L) -> Self
1775 where
1776 L: MiddlewareLayer,
1777 {
1778 self.layers.push(Box::new(layer));
1779 self
1780 }
1781
1782 pub fn build(self) -> RustApi {
1784 let mut app = RustApi::new().mount_auto_routes_grouped();
1785
1786 if let Some(limit) = self.body_limit {
1788 app = app.body_limit(limit);
1789 }
1790
1791 app = app.openapi_info(
1792 &self.api_title,
1793 &self.api_version,
1794 self.api_description.as_deref(),
1795 );
1796
1797 #[cfg(feature = "swagger-ui")]
1798 if self.docs_enabled {
1799 if let Some(path) = self.docs_path {
1800 app = app.docs(&path);
1801 }
1802 }
1803
1804 app.layers.extend(self.layers);
1807
1808 app
1809 }
1810
1811 pub async fn run(
1813 self,
1814 addr: impl AsRef<str>,
1815 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1816 self.build().run(addr.as_ref()).await
1817 }
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822 use super::RustApi;
1823 use crate::extract::{FromRequestParts, State};
1824 use crate::path_params::PathParams;
1825 use crate::request::Request;
1826 use crate::router::{get, post, Router};
1827 use bytes::Bytes;
1828 use http::Method;
1829 use proptest::prelude::*;
1830
1831 #[test]
1832 fn state_is_available_via_extractor() {
1833 let app = RustApi::new().state(123u32);
1834 let router = app.into_router();
1835
1836 let req = http::Request::builder()
1837 .method(Method::GET)
1838 .uri("/test")
1839 .body(())
1840 .unwrap();
1841 let (parts, _) = req.into_parts();
1842
1843 let request = Request::new(
1844 parts,
1845 crate::request::BodyVariant::Buffered(Bytes::new()),
1846 router.state_ref(),
1847 PathParams::new(),
1848 );
1849 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
1850 assert_eq!(value, 123u32);
1851 }
1852
1853 #[test]
1854 fn test_path_param_type_inference_integer() {
1855 use super::infer_path_param_schema;
1856
1857 let int_params = [
1859 "page",
1860 "limit",
1861 "offset",
1862 "count",
1863 "item_count",
1864 "year",
1865 "month",
1866 "day",
1867 "index",
1868 "position",
1869 ];
1870
1871 for name in int_params {
1872 let schema = infer_path_param_schema(name);
1873 match schema {
1874 rustapi_openapi::SchemaRef::Inline(v) => {
1875 assert_eq!(
1876 v.get("type").and_then(|v| v.as_str()),
1877 Some("integer"),
1878 "Expected '{}' to be inferred as integer",
1879 name
1880 );
1881 }
1882 _ => panic!("Expected inline schema for '{}'", name),
1883 }
1884 }
1885 }
1886
1887 #[test]
1888 fn test_path_param_type_inference_uuid() {
1889 use super::infer_path_param_schema;
1890
1891 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
1893
1894 for name in uuid_params {
1895 let schema = infer_path_param_schema(name);
1896 match schema {
1897 rustapi_openapi::SchemaRef::Inline(v) => {
1898 assert_eq!(
1899 v.get("type").and_then(|v| v.as_str()),
1900 Some("string"),
1901 "Expected '{}' to be inferred as string",
1902 name
1903 );
1904 assert_eq!(
1905 v.get("format").and_then(|v| v.as_str()),
1906 Some("uuid"),
1907 "Expected '{}' to have uuid format",
1908 name
1909 );
1910 }
1911 _ => panic!("Expected inline schema for '{}'", name),
1912 }
1913 }
1914 }
1915
1916 #[test]
1917 fn test_path_param_type_inference_string() {
1918 use super::infer_path_param_schema;
1919
1920 let string_params = [
1922 "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId",
1923 ];
1924
1925 for name in string_params {
1926 let schema = infer_path_param_schema(name);
1927 match schema {
1928 rustapi_openapi::SchemaRef::Inline(v) => {
1929 assert_eq!(
1930 v.get("type").and_then(|v| v.as_str()),
1931 Some("string"),
1932 "Expected '{}' to be inferred as string",
1933 name
1934 );
1935 assert!(
1936 v.get("format").is_none()
1937 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
1938 "Expected '{}' to NOT have uuid format",
1939 name
1940 );
1941 }
1942 _ => panic!("Expected inline schema for '{}'", name),
1943 }
1944 }
1945 }
1946
1947 #[test]
1948 fn test_schema_type_to_openapi_schema() {
1949 use super::schema_type_to_openapi_schema;
1950
1951 let uuid_schema = schema_type_to_openapi_schema("uuid");
1953 match uuid_schema {
1954 rustapi_openapi::SchemaRef::Inline(v) => {
1955 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
1956 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid"));
1957 }
1958 _ => panic!("Expected inline schema for uuid"),
1959 }
1960
1961 for schema_type in ["integer", "int", "int64", "i64"] {
1963 let schema = schema_type_to_openapi_schema(schema_type);
1964 match schema {
1965 rustapi_openapi::SchemaRef::Inline(v) => {
1966 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
1967 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64"));
1968 }
1969 _ => panic!("Expected inline schema for {}", schema_type),
1970 }
1971 }
1972
1973 let int32_schema = schema_type_to_openapi_schema("int32");
1975 match int32_schema {
1976 rustapi_openapi::SchemaRef::Inline(v) => {
1977 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
1978 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32"));
1979 }
1980 _ => panic!("Expected inline schema for int32"),
1981 }
1982
1983 for schema_type in ["number", "float"] {
1985 let schema = schema_type_to_openapi_schema(schema_type);
1986 match schema {
1987 rustapi_openapi::SchemaRef::Inline(v) => {
1988 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number"));
1989 }
1990 _ => panic!("Expected inline schema for {}", schema_type),
1991 }
1992 }
1993
1994 for schema_type in ["boolean", "bool"] {
1996 let schema = schema_type_to_openapi_schema(schema_type);
1997 match schema {
1998 rustapi_openapi::SchemaRef::Inline(v) => {
1999 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean"));
2000 }
2001 _ => panic!("Expected inline schema for {}", schema_type),
2002 }
2003 }
2004
2005 let string_schema = schema_type_to_openapi_schema("string");
2007 match string_schema {
2008 rustapi_openapi::SchemaRef::Inline(v) => {
2009 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2010 }
2011 _ => panic!("Expected inline schema for string"),
2012 }
2013 }
2014
2015 proptest! {
2022 #![proptest_config(ProptestConfig::with_cases(100))]
2023
2024 #[test]
2029 fn prop_nested_routes_in_openapi_spec(
2030 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2032 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2034 has_param in any::<bool>(),
2035 ) {
2036 async fn handler() -> &'static str { "handler" }
2037
2038 let prefix = format!("/{}", prefix_segments.join("/"));
2040
2041 let mut route_path = format!("/{}", route_segments.join("/"));
2043 if has_param {
2044 route_path.push_str("/{id}");
2045 }
2046
2047 let nested_router = Router::new().route(&route_path, get(handler));
2049 let app = RustApi::new().nest(&prefix, nested_router);
2050
2051 let expected_openapi_path = format!("{}{}", prefix, route_path);
2053
2054 let spec = app.openapi_spec();
2056
2057 prop_assert!(
2059 spec.paths.contains_key(&expected_openapi_path),
2060 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2061 expected_openapi_path,
2062 spec.paths.keys().collect::<Vec<_>>()
2063 );
2064
2065 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2067 prop_assert!(
2068 path_item.get.is_some(),
2069 "GET operation should exist for path '{}'",
2070 expected_openapi_path
2071 );
2072 }
2073
2074 #[test]
2079 fn prop_multiple_methods_preserved_in_openapi(
2080 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2081 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2082 ) {
2083 async fn get_handler() -> &'static str { "get" }
2084 async fn post_handler() -> &'static str { "post" }
2085
2086 let prefix = format!("/{}", prefix_segments.join("/"));
2088 let route_path = format!("/{}", route_segments.join("/"));
2089
2090 let get_route_path = format!("{}/get", route_path);
2093 let post_route_path = format!("{}/post", route_path);
2094 let nested_router = Router::new()
2095 .route(&get_route_path, get(get_handler))
2096 .route(&post_route_path, post(post_handler));
2097 let app = RustApi::new().nest(&prefix, nested_router);
2098
2099 let expected_get_path = format!("{}{}", prefix, get_route_path);
2101 let expected_post_path = format!("{}{}", prefix, post_route_path);
2102
2103 let spec = app.openapi_spec();
2105
2106 prop_assert!(
2108 spec.paths.contains_key(&expected_get_path),
2109 "Expected OpenAPI path '{}' not found",
2110 expected_get_path
2111 );
2112 prop_assert!(
2113 spec.paths.contains_key(&expected_post_path),
2114 "Expected OpenAPI path '{}' not found",
2115 expected_post_path
2116 );
2117
2118 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
2120 prop_assert!(
2121 get_path_item.get.is_some(),
2122 "GET operation should exist for path '{}'",
2123 expected_get_path
2124 );
2125
2126 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
2128 prop_assert!(
2129 post_path_item.post.is_some(),
2130 "POST operation should exist for path '{}'",
2131 expected_post_path
2132 );
2133 }
2134
2135 #[test]
2140 fn prop_path_params_in_openapi_after_nesting(
2141 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2142 param_name in "[a-z][a-z0-9]{0,5}",
2143 ) {
2144 async fn handler() -> &'static str { "handler" }
2145
2146 let prefix = format!("/{}", prefix_segments.join("/"));
2148 let route_path = format!("/{{{}}}", param_name);
2149
2150 let nested_router = Router::new().route(&route_path, get(handler));
2152 let app = RustApi::new().nest(&prefix, nested_router);
2153
2154 let expected_openapi_path = format!("{}{}", prefix, route_path);
2156
2157 let spec = app.openapi_spec();
2159
2160 prop_assert!(
2162 spec.paths.contains_key(&expected_openapi_path),
2163 "Expected OpenAPI path '{}' not found",
2164 expected_openapi_path
2165 );
2166
2167 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2169 let get_op = path_item.get.as_ref().unwrap();
2170
2171 prop_assert!(
2172 !get_op.parameters.is_empty(),
2173 "Operation should have parameters for path '{}'",
2174 expected_openapi_path
2175 );
2176
2177 let params = &get_op.parameters;
2178 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
2179 prop_assert!(
2180 has_param,
2181 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
2182 param_name,
2183 params.iter().map(|p| &p.name).collect::<Vec<_>>()
2184 );
2185 }
2186 }
2187
2188 proptest! {
2196 #![proptest_config(ProptestConfig::with_cases(100))]
2197
2198 #[test]
2203 fn prop_rustapi_nest_delegates_to_router_nest(
2204 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2205 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2206 has_param in any::<bool>(),
2207 ) {
2208 async fn handler() -> &'static str { "handler" }
2209
2210 let prefix = format!("/{}", prefix_segments.join("/"));
2212
2213 let mut route_path = format!("/{}", route_segments.join("/"));
2215 if has_param {
2216 route_path.push_str("/{id}");
2217 }
2218
2219 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2221 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2222
2223 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2225 let rustapi_router = rustapi_app.into_router();
2226
2227 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2229
2230 let rustapi_routes = rustapi_router.registered_routes();
2232 let router_routes = router_app.registered_routes();
2233
2234 prop_assert_eq!(
2235 rustapi_routes.len(),
2236 router_routes.len(),
2237 "RustApi and Router should have same number of routes"
2238 );
2239
2240 for (path, info) in router_routes {
2242 prop_assert!(
2243 rustapi_routes.contains_key(path),
2244 "Route '{}' from Router should exist in RustApi routes",
2245 path
2246 );
2247
2248 let rustapi_info = rustapi_routes.get(path).unwrap();
2249 prop_assert_eq!(
2250 &info.path, &rustapi_info.path,
2251 "Display paths should match for route '{}'",
2252 path
2253 );
2254 prop_assert_eq!(
2255 info.methods.len(), rustapi_info.methods.len(),
2256 "Method count should match for route '{}'",
2257 path
2258 );
2259 }
2260 }
2261
2262 #[test]
2267 fn prop_rustapi_nest_includes_routes_in_openapi(
2268 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2269 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2270 has_param in any::<bool>(),
2271 ) {
2272 async fn handler() -> &'static str { "handler" }
2273
2274 let prefix = format!("/{}", prefix_segments.join("/"));
2276
2277 let mut route_path = format!("/{}", route_segments.join("/"));
2279 if has_param {
2280 route_path.push_str("/{id}");
2281 }
2282
2283 let nested_router = Router::new().route(&route_path, get(handler));
2285 let app = RustApi::new().nest(&prefix, nested_router);
2286
2287 let expected_openapi_path = format!("{}{}", prefix, route_path);
2289
2290 let spec = app.openapi_spec();
2292
2293 prop_assert!(
2295 spec.paths.contains_key(&expected_openapi_path),
2296 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2297 expected_openapi_path,
2298 spec.paths.keys().collect::<Vec<_>>()
2299 );
2300
2301 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2303 prop_assert!(
2304 path_item.get.is_some(),
2305 "GET operation should exist for path '{}'",
2306 expected_openapi_path
2307 );
2308 }
2309
2310 #[test]
2315 fn prop_rustapi_nest_route_matching_identical(
2316 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2317 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2318 param_value in "[a-z0-9]{1,10}",
2319 ) {
2320 use crate::router::RouteMatch;
2321
2322 async fn handler() -> &'static str { "handler" }
2323
2324 let prefix = format!("/{}", prefix_segments.join("/"));
2326 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
2327
2328 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2330 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2331
2332 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2334 let rustapi_router = rustapi_app.into_router();
2335 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2336
2337 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
2339
2340 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
2342 let router_match = router_app.match_route(&full_path, &Method::GET);
2343
2344 match (rustapi_match, router_match) {
2346 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
2347 prop_assert_eq!(
2348 rustapi_params.len(),
2349 router_params.len(),
2350 "Parameter count should match"
2351 );
2352 for (key, value) in &router_params {
2353 prop_assert!(
2354 rustapi_params.contains_key(key),
2355 "RustApi should have parameter '{}'",
2356 key
2357 );
2358 prop_assert_eq!(
2359 rustapi_params.get(key).unwrap(),
2360 value,
2361 "Parameter '{}' value should match",
2362 key
2363 );
2364 }
2365 }
2366 (rustapi_result, router_result) => {
2367 prop_assert!(
2368 false,
2369 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
2370 match rustapi_result {
2371 RouteMatch::Found { .. } => "Found",
2372 RouteMatch::NotFound => "NotFound",
2373 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2374 },
2375 match router_result {
2376 RouteMatch::Found { .. } => "Found",
2377 RouteMatch::NotFound => "NotFound",
2378 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2379 }
2380 );
2381 }
2382 }
2383 }
2384 }
2385
2386 #[test]
2388 fn test_openapi_operations_propagated_during_nesting() {
2389 async fn list_users() -> &'static str {
2390 "list users"
2391 }
2392 async fn get_user() -> &'static str {
2393 "get user"
2394 }
2395 async fn create_user() -> &'static str {
2396 "create user"
2397 }
2398
2399 let users_router = Router::new()
2402 .route("/", get(list_users))
2403 .route("/create", post(create_user))
2404 .route("/{id}", get(get_user));
2405
2406 let app = RustApi::new().nest("/api/v1/users", users_router);
2408
2409 let spec = app.openapi_spec();
2410
2411 assert!(
2413 spec.paths.contains_key("/api/v1/users"),
2414 "Should have /api/v1/users path"
2415 );
2416 let users_path = spec.paths.get("/api/v1/users").unwrap();
2417 assert!(users_path.get.is_some(), "Should have GET operation");
2418
2419 assert!(
2421 spec.paths.contains_key("/api/v1/users/create"),
2422 "Should have /api/v1/users/create path"
2423 );
2424 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
2425 assert!(create_path.post.is_some(), "Should have POST operation");
2426
2427 assert!(
2429 spec.paths.contains_key("/api/v1/users/{id}"),
2430 "Should have /api/v1/users/{{id}} path"
2431 );
2432 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
2433 assert!(
2434 user_path.get.is_some(),
2435 "Should have GET operation for user by id"
2436 );
2437
2438 let get_user_op = user_path.get.as_ref().unwrap();
2440 assert!(!get_user_op.parameters.is_empty(), "Should have parameters");
2441 let params = &get_user_op.parameters;
2442 assert!(
2443 params
2444 .iter()
2445 .any(|p| p.name == "id" && p.location == "path"),
2446 "Should have 'id' path parameter"
2447 );
2448 }
2449
2450 #[test]
2452 fn test_openapi_spec_empty_without_routes() {
2453 let app = RustApi::new();
2454 let spec = app.openapi_spec();
2455
2456 assert!(
2458 spec.paths.is_empty(),
2459 "OpenAPI spec should have no paths without routes"
2460 );
2461 }
2462
2463 #[test]
2468 fn test_rustapi_nest_delegates_to_router_nest() {
2469 use crate::router::RouteMatch;
2470
2471 async fn list_users() -> &'static str {
2472 "list users"
2473 }
2474 async fn get_user() -> &'static str {
2475 "get user"
2476 }
2477 async fn create_user() -> &'static str {
2478 "create user"
2479 }
2480
2481 let users_router = Router::new()
2483 .route("/", get(list_users))
2484 .route("/create", post(create_user))
2485 .route("/{id}", get(get_user));
2486
2487 let app = RustApi::new().nest("/api/v1/users", users_router);
2489 let router = app.into_router();
2490
2491 let routes = router.registered_routes();
2493 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
2494
2495 assert!(
2497 routes.contains_key("/api/v1/users"),
2498 "Should have /api/v1/users route"
2499 );
2500 assert!(
2501 routes.contains_key("/api/v1/users/create"),
2502 "Should have /api/v1/users/create route"
2503 );
2504 assert!(
2505 routes.contains_key("/api/v1/users/:id"),
2506 "Should have /api/v1/users/:id route"
2507 );
2508
2509 match router.match_route("/api/v1/users", &Method::GET) {
2511 RouteMatch::Found { params, .. } => {
2512 assert!(params.is_empty(), "Root route should have no params");
2513 }
2514 _ => panic!("GET /api/v1/users should be found"),
2515 }
2516
2517 match router.match_route("/api/v1/users/create", &Method::POST) {
2518 RouteMatch::Found { params, .. } => {
2519 assert!(params.is_empty(), "Create route should have no params");
2520 }
2521 _ => panic!("POST /api/v1/users/create should be found"),
2522 }
2523
2524 match router.match_route("/api/v1/users/123", &Method::GET) {
2525 RouteMatch::Found { params, .. } => {
2526 assert_eq!(
2527 params.get("id"),
2528 Some(&"123".to_string()),
2529 "Should extract id param"
2530 );
2531 }
2532 _ => panic!("GET /api/v1/users/123 should be found"),
2533 }
2534
2535 match router.match_route("/api/v1/users", &Method::DELETE) {
2537 RouteMatch::MethodNotAllowed { allowed } => {
2538 assert!(allowed.contains(&Method::GET), "Should allow GET");
2539 }
2540 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
2541 }
2542 }
2543
2544 #[test]
2549 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
2550 async fn list_items() -> &'static str {
2551 "list items"
2552 }
2553 async fn get_item() -> &'static str {
2554 "get item"
2555 }
2556
2557 let items_router = Router::new()
2559 .route("/", get(list_items))
2560 .route("/{item_id}", get(get_item));
2561
2562 let app = RustApi::new().nest("/api/items", items_router);
2564
2565 let spec = app.openapi_spec();
2567
2568 assert!(
2570 spec.paths.contains_key("/api/items"),
2571 "Should have /api/items in OpenAPI"
2572 );
2573 assert!(
2574 spec.paths.contains_key("/api/items/{item_id}"),
2575 "Should have /api/items/{{item_id}} in OpenAPI"
2576 );
2577
2578 let list_path = spec.paths.get("/api/items").unwrap();
2580 assert!(
2581 list_path.get.is_some(),
2582 "Should have GET operation for /api/items"
2583 );
2584
2585 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
2586 assert!(
2587 get_path.get.is_some(),
2588 "Should have GET operation for /api/items/{{item_id}}"
2589 );
2590
2591 let get_op = get_path.get.as_ref().unwrap();
2593 assert!(!get_op.parameters.is_empty(), "Should have parameters");
2594 let params = &get_op.parameters;
2595 assert!(
2596 params
2597 .iter()
2598 .any(|p| p.name == "item_id" && p.location == "path"),
2599 "Should have 'item_id' path parameter"
2600 );
2601 }
2602}