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 (method, op) in &method_router.operations {
547 let mut op = op.clone();
548 add_path_params_to_operation(path, &mut op, &BTreeMap::new());
549 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
550 }
551
552 self.router = self.router.route(path, method_router);
553 self
554 }
555
556 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
558 self.route(P::PATH, method_router)
559 }
560
561 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
565 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
566 self.route(path, method_router)
567 }
568
569 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
587 let method_enum = match route.method {
588 "GET" => http::Method::GET,
589 "POST" => http::Method::POST,
590 "PUT" => http::Method::PUT,
591 "DELETE" => http::Method::DELETE,
592 "PATCH" => http::Method::PATCH,
593 _ => http::Method::GET,
594 };
595
596 let mut op = route.operation;
598 add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
599 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
600
601 self.route_with_method(route.path, method_enum, route.handler)
602 }
603
604 fn route_with_method(
606 self,
607 path: &str,
608 method: http::Method,
609 handler: crate::handler::BoxedHandler,
610 ) -> Self {
611 use crate::router::MethodRouter;
612 let path = if !path.starts_with('/') {
621 format!("/{}", path)
622 } else {
623 path.to_string()
624 };
625
626 let mut handlers = std::collections::HashMap::new();
635 handlers.insert(method, handler);
636
637 let method_router = MethodRouter::from_boxed(handlers);
638 self.route(&path, method_router)
639 }
640
641 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
657 let normalized_prefix = normalize_prefix_for_openapi(prefix);
659
660 for (matchit_path, method_router) in router.method_routers() {
663 let display_path = router
665 .registered_routes()
666 .get(matchit_path)
667 .map(|info| info.path.clone())
668 .unwrap_or_else(|| matchit_path.clone());
669
670 let prefixed_path = if display_path == "/" {
672 normalized_prefix.clone()
673 } else {
674 format!("{}{}", normalized_prefix, display_path)
675 };
676
677 for (method, op) in &method_router.operations {
679 let mut op = op.clone();
680 add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
681 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
682 }
683 }
684
685 self.router = self.router.nest(prefix, router);
687 self
688 }
689
690 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
719 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
720 }
721
722 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
739 use crate::router::MethodRouter;
740 use std::collections::HashMap;
741
742 let prefix = config.prefix.clone();
743 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
744
745 let handler: crate::handler::BoxedHandler =
747 std::sync::Arc::new(move |req: crate::Request| {
748 let config = config.clone();
749 let path = req.uri().path().to_string();
750
751 Box::pin(async move {
752 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
753
754 match crate::static_files::StaticFile::serve(relative_path, &config).await {
755 Ok(response) => response,
756 Err(err) => err.into_response(),
757 }
758 })
759 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
760 });
761
762 let mut handlers = HashMap::new();
763 handlers.insert(http::Method::GET, handler);
764 let method_router = MethodRouter::from_boxed(handlers);
765
766 self.route(&catch_all_path, method_router)
767 }
768
769 #[cfg(feature = "compression")]
786 pub fn compression(self) -> Self {
787 self.layer(crate::middleware::CompressionLayer::new())
788 }
789
790 #[cfg(feature = "compression")]
806 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
807 self.layer(crate::middleware::CompressionLayer::with_config(config))
808 }
809
810 #[cfg(feature = "swagger-ui")]
834 pub fn docs(self, path: &str) -> Self {
835 let title = self.openapi_spec.info.title.clone();
836 let version = self.openapi_spec.info.version.clone();
837 let description = self.openapi_spec.info.description.clone();
838
839 self.docs_with_info(path, &title, &version, description.as_deref())
840 }
841
842 #[cfg(feature = "swagger-ui")]
851 pub fn docs_with_info(
852 mut self,
853 path: &str,
854 title: &str,
855 version: &str,
856 description: Option<&str>,
857 ) -> Self {
858 use crate::router::get;
859 self.openapi_spec.info.title = title.to_string();
861 self.openapi_spec.info.version = version.to_string();
862 if let Some(desc) = description {
863 self.openapi_spec.info.description = Some(desc.to_string());
864 }
865
866 let path = path.trim_end_matches('/');
867 let openapi_path = format!("{}/openapi.json", path);
868
869 let spec_value = self.openapi_spec.to_json();
871 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
872 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
874 "{}".to_string()
875 });
876 let openapi_url = openapi_path.clone();
877
878 let spec_handler = move || {
880 let json = spec_json.clone();
881 async move {
882 http::Response::builder()
883 .status(http::StatusCode::OK)
884 .header(http::header::CONTENT_TYPE, "application/json")
885 .body(crate::response::Body::from(json))
886 .unwrap_or_else(|e| {
887 tracing::error!("Failed to build response: {}", e);
888 http::Response::builder()
889 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
890 .body(crate::response::Body::from("Internal Server Error"))
891 .unwrap()
892 })
893 }
894 };
895
896 let docs_handler = move || {
898 let url = openapi_url.clone();
899 async move {
900 let response = rustapi_openapi::swagger_ui_html(&url);
901 response.map(crate::response::Body::Full)
902 }
903 };
904
905 self.route(&openapi_path, get(spec_handler))
906 .route(path, get(docs_handler))
907 }
908
909 #[cfg(feature = "swagger-ui")]
925 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
926 let title = self.openapi_spec.info.title.clone();
927 let version = self.openapi_spec.info.version.clone();
928 let description = self.openapi_spec.info.description.clone();
929
930 self.docs_with_auth_and_info(
931 path,
932 username,
933 password,
934 &title,
935 &version,
936 description.as_deref(),
937 )
938 }
939
940 #[cfg(feature = "swagger-ui")]
956 pub fn docs_with_auth_and_info(
957 mut self,
958 path: &str,
959 username: &str,
960 password: &str,
961 title: &str,
962 version: &str,
963 description: Option<&str>,
964 ) -> Self {
965 use crate::router::MethodRouter;
966 use base64::{engine::general_purpose::STANDARD, Engine};
967 use std::collections::HashMap;
968
969 self.openapi_spec.info.title = title.to_string();
971 self.openapi_spec.info.version = version.to_string();
972 if let Some(desc) = description {
973 self.openapi_spec.info.description = Some(desc.to_string());
974 }
975
976 let path = path.trim_end_matches('/');
977 let openapi_path = format!("{}/openapi.json", path);
978
979 let credentials = format!("{}:{}", username, password);
981 let encoded = STANDARD.encode(credentials.as_bytes());
982 let expected_auth = format!("Basic {}", encoded);
983
984 let spec_value = self.openapi_spec.to_json();
986 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
987 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
988 "{}".to_string()
989 });
990 let openapi_url = openapi_path.clone();
991 let expected_auth_spec = expected_auth.clone();
992 let expected_auth_docs = expected_auth;
993
994 let spec_handler: crate::handler::BoxedHandler =
996 std::sync::Arc::new(move |req: crate::Request| {
997 let json = spec_json.clone();
998 let expected = expected_auth_spec.clone();
999 Box::pin(async move {
1000 if !check_basic_auth(&req, &expected) {
1001 return unauthorized_response();
1002 }
1003 http::Response::builder()
1004 .status(http::StatusCode::OK)
1005 .header(http::header::CONTENT_TYPE, "application/json")
1006 .body(crate::response::Body::from(json))
1007 .unwrap_or_else(|e| {
1008 tracing::error!("Failed to build response: {}", e);
1009 http::Response::builder()
1010 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1011 .body(crate::response::Body::from("Internal Server Error"))
1012 .unwrap()
1013 })
1014 })
1015 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1016 });
1017
1018 let docs_handler: crate::handler::BoxedHandler =
1020 std::sync::Arc::new(move |req: crate::Request| {
1021 let url = openapi_url.clone();
1022 let expected = expected_auth_docs.clone();
1023 Box::pin(async move {
1024 if !check_basic_auth(&req, &expected) {
1025 return unauthorized_response();
1026 }
1027 let response = rustapi_openapi::swagger_ui_html(&url);
1028 response.map(crate::response::Body::Full)
1029 })
1030 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1031 });
1032
1033 let mut spec_handlers = HashMap::new();
1035 spec_handlers.insert(http::Method::GET, spec_handler);
1036 let spec_router = MethodRouter::from_boxed(spec_handlers);
1037
1038 let mut docs_handlers = HashMap::new();
1039 docs_handlers.insert(http::Method::GET, docs_handler);
1040 let docs_router = MethodRouter::from_boxed(docs_handlers);
1041
1042 self.route(&openapi_path, spec_router)
1043 .route(path, docs_router)
1044 }
1045
1046 pub fn status_page(self) -> Self {
1048 self.status_page_with_config(crate::status::StatusConfig::default())
1049 }
1050
1051 pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self {
1053 self.status_config = Some(config);
1054 self
1055 }
1056
1057 pub fn health_endpoints(mut self) -> Self {
1062 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1063 if self.health_check.is_none() {
1064 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1065 }
1066 self
1067 }
1068
1069 pub fn health_endpoints_with_config(
1071 mut self,
1072 config: crate::health::HealthEndpointConfig,
1073 ) -> Self {
1074 self.health_endpoint_config = Some(config);
1075 if self.health_check.is_none() {
1076 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1077 }
1078 self
1079 }
1080
1081 pub fn with_health_check(mut self, health_check: crate::health::HealthCheck) -> Self {
1086 self.health_check = Some(health_check);
1087 if self.health_endpoint_config.is_none() {
1088 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1089 }
1090 self
1091 }
1092
1093 pub fn production_defaults(self, service_name: impl Into<String>) -> Self {
1100 self.production_defaults_with_config(ProductionDefaultsConfig::new(service_name))
1101 }
1102
1103 pub fn production_defaults_with_config(mut self, config: ProductionDefaultsConfig) -> Self {
1105 if config.enable_request_id {
1106 self = self.layer(crate::middleware::RequestIdLayer::new());
1107 }
1108
1109 if config.enable_tracing {
1110 let mut tracing_layer =
1111 crate::middleware::TracingLayer::with_level(config.tracing_level)
1112 .with_field("service", config.service_name.clone())
1113 .with_field("environment", crate::error::get_environment().to_string());
1114
1115 if let Some(version) = &config.version {
1116 tracing_layer = tracing_layer.with_field("version", version.clone());
1117 }
1118
1119 self = self.layer(tracing_layer);
1120 }
1121
1122 if config.enable_health_endpoints {
1123 if self.health_check.is_none() {
1124 let mut builder = crate::health::HealthCheckBuilder::default();
1125 if let Some(version) = &config.version {
1126 builder = builder.version(version.clone());
1127 }
1128 self.health_check = Some(builder.build());
1129 }
1130
1131 if self.health_endpoint_config.is_none() {
1132 self.health_endpoint_config =
1133 Some(config.health_endpoint_config.unwrap_or_default());
1134 }
1135 }
1136
1137 self
1138 }
1139
1140 fn print_hot_reload_banner(&self, addr: &str) {
1142 if !self.hot_reload {
1143 return;
1144 }
1145
1146 std::env::set_var("RUSTAPI_HOT_RELOAD", "1");
1148
1149 let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD")
1150 .map(|v| v == "1")
1151 .unwrap_or(false);
1152
1153 tracing::info!("🔄 Hot-reload mode enabled");
1154
1155 if is_under_watcher {
1156 tracing::info!(" File watcher active — changes will trigger rebuild + restart");
1157 } else {
1158 tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload");
1159 }
1160
1161 tracing::info!(" Listening on http://{addr}");
1162 }
1163
1164 fn apply_health_endpoints(&mut self) {
1166 if let Some(config) = &self.health_endpoint_config {
1167 use crate::router::get;
1168
1169 let health_check = self
1170 .health_check
1171 .clone()
1172 .unwrap_or_else(|| crate::health::HealthCheckBuilder::default().build());
1173
1174 let health_path = config.health_path.clone();
1175 let readiness_path = config.readiness_path.clone();
1176 let liveness_path = config.liveness_path.clone();
1177
1178 let health_handler = {
1179 let health_check = health_check.clone();
1180 move || {
1181 let health_check = health_check.clone();
1182 async move { crate::health::health_response(health_check).await }
1183 }
1184 };
1185
1186 let readiness_handler = {
1187 let health_check = health_check.clone();
1188 move || {
1189 let health_check = health_check.clone();
1190 async move { crate::health::readiness_response(health_check).await }
1191 }
1192 };
1193
1194 let liveness_handler = || async { crate::health::liveness_response().await };
1195
1196 let router = std::mem::take(&mut self.router);
1197 self.router = router
1198 .route(&health_path, get(health_handler))
1199 .route(&readiness_path, get(readiness_handler))
1200 .route(&liveness_path, get(liveness_handler));
1201 }
1202 }
1203
1204 fn apply_status_page(&mut self) {
1205 if let Some(config) = &self.status_config {
1206 let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new());
1207
1208 self.layers
1210 .push(Box::new(crate::status::StatusLayer::new(monitor.clone())));
1211
1212 use crate::router::MethodRouter;
1214 use std::collections::HashMap;
1215
1216 let monitor = monitor.clone();
1217 let config = config.clone();
1218 let path = config.path.clone(); let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| {
1221 let monitor = monitor.clone();
1222 let config = config.clone();
1223 Box::pin(async move {
1224 crate::status::status_handler(monitor, config)
1225 .await
1226 .into_response()
1227 })
1228 });
1229
1230 let mut handlers = HashMap::new();
1231 handlers.insert(http::Method::GET, handler);
1232 let method_router = MethodRouter::from_boxed(handlers);
1233
1234 let router = std::mem::take(&mut self.router);
1236 self.router = router.route(&path, method_router);
1237 }
1238 }
1239
1240 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1251 self.print_hot_reload_banner(addr);
1253
1254 self.apply_health_endpoints();
1256
1257 self.apply_status_page();
1259
1260 if let Some(limit) = self.body_limit {
1262 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1264 }
1265
1266 for hook in self.lifecycle_hooks.on_start {
1268 hook().await;
1269 }
1270
1271 let server = Server::new(self.router, self.layers, self.interceptors);
1272 server.run(addr).await
1273 }
1274
1275 pub async fn run_with_shutdown<F>(
1277 mut self,
1278 addr: impl AsRef<str>,
1279 signal: F,
1280 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1281 where
1282 F: std::future::Future<Output = ()> + Send + 'static,
1283 {
1284 self.print_hot_reload_banner(addr.as_ref());
1286
1287 self.apply_health_endpoints();
1289
1290 self.apply_status_page();
1292
1293 if let Some(limit) = self.body_limit {
1294 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1295 }
1296
1297 for hook in self.lifecycle_hooks.on_start {
1299 hook().await;
1300 }
1301
1302 let shutdown_hooks = self.lifecycle_hooks.on_shutdown;
1304 let wrapped_signal = async move {
1305 signal.await;
1306 for hook in shutdown_hooks {
1308 hook().await;
1309 }
1310 };
1311
1312 let server = Server::new(self.router, self.layers, self.interceptors);
1313 server
1314 .run_with_shutdown(addr.as_ref(), wrapped_signal)
1315 .await
1316 }
1317
1318 pub fn into_router(self) -> Router {
1320 self.router
1321 }
1322
1323 pub fn layers(&self) -> &LayerStack {
1325 &self.layers
1326 }
1327
1328 pub fn interceptors(&self) -> &InterceptorChain {
1330 &self.interceptors
1331 }
1332
1333 #[cfg(feature = "http3")]
1347 pub async fn run_http3(
1348 mut self,
1349 config: crate::http3::Http3Config,
1350 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1351 use std::sync::Arc;
1352
1353 self.apply_health_endpoints();
1355
1356 self.apply_status_page();
1358
1359 if let Some(limit) = self.body_limit {
1361 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1362 }
1363
1364 let server = crate::http3::Http3Server::new(
1365 &config,
1366 Arc::new(self.router),
1367 Arc::new(self.layers),
1368 Arc::new(self.interceptors),
1369 )
1370 .await?;
1371
1372 server.run().await
1373 }
1374
1375 #[cfg(feature = "http3-dev")]
1389 pub async fn run_http3_dev(
1390 mut self,
1391 addr: &str,
1392 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1393 use std::sync::Arc;
1394
1395 self.apply_health_endpoints();
1397
1398 self.apply_status_page();
1400
1401 if let Some(limit) = self.body_limit {
1403 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1404 }
1405
1406 let server = crate::http3::Http3Server::new_with_self_signed(
1407 addr,
1408 Arc::new(self.router),
1409 Arc::new(self.layers),
1410 Arc::new(self.interceptors),
1411 )
1412 .await?;
1413
1414 server.run().await
1415 }
1416
1417 #[cfg(feature = "http3")]
1428 pub fn with_http3(mut self, cert_path: impl Into<String>, key_path: impl Into<String>) -> Self {
1429 self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path));
1430 self
1431 }
1432
1433 #[cfg(feature = "http3")]
1448 pub async fn run_dual_stack(
1449 mut self,
1450 http_addr: &str,
1451 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1452 use std::sync::Arc;
1453
1454 let mut config = self
1455 .http3_config
1456 .take()
1457 .ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
1458
1459 let http_socket: std::net::SocketAddr = http_addr.parse()?;
1460 config.bind_addr = if http_socket.ip().is_ipv6() {
1461 format!("[{}]", http_socket.ip())
1462 } else {
1463 http_socket.ip().to_string()
1464 };
1465 config.port = http_socket.port();
1466 let http_addr = http_socket.to_string();
1467
1468 self.apply_health_endpoints();
1470
1471 self.apply_status_page();
1473
1474 if let Some(limit) = self.body_limit {
1476 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1477 }
1478
1479 let router = Arc::new(self.router);
1480 let layers = Arc::new(self.layers);
1481 let interceptors = Arc::new(self.interceptors);
1482
1483 let http1_server =
1484 Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1485 let http3_server =
1486 crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1487
1488 tracing::info!(
1489 http1_addr = %http_addr,
1490 http3_addr = %config.socket_addr(),
1491 "Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1492 );
1493
1494 tokio::try_join!(
1495 http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1496 http3_server.run_with_shutdown(std::future::pending::<()>()),
1497 )?;
1498
1499 Ok(())
1500 }
1501}
1502
1503fn add_path_params_to_operation(
1504 path: &str,
1505 op: &mut rustapi_openapi::Operation,
1506 param_schemas: &BTreeMap<String, String>,
1507) {
1508 let mut params: Vec<String> = Vec::new();
1509 let mut in_brace = false;
1510 let mut current = String::new();
1511
1512 for ch in path.chars() {
1513 match ch {
1514 '{' => {
1515 in_brace = true;
1516 current.clear();
1517 }
1518 '}' => {
1519 if in_brace {
1520 in_brace = false;
1521 if !current.is_empty() {
1522 params.push(current.clone());
1523 }
1524 }
1525 }
1526 _ => {
1527 if in_brace {
1528 current.push(ch);
1529 }
1530 }
1531 }
1532 }
1533
1534 if params.is_empty() {
1535 return;
1536 }
1537
1538 let op_params = &mut op.parameters;
1539
1540 for name in params {
1541 let already = op_params
1542 .iter()
1543 .any(|p| p.location == "path" && p.name == name);
1544 if already {
1545 continue;
1546 }
1547
1548 let schema = if let Some(schema_type) = param_schemas.get(&name) {
1550 schema_type_to_openapi_schema(schema_type)
1551 } else {
1552 infer_path_param_schema(&name)
1553 };
1554
1555 op_params.push(rustapi_openapi::Parameter {
1556 name,
1557 location: "path".to_string(),
1558 required: true,
1559 description: None,
1560 deprecated: None,
1561 schema: Some(schema),
1562 });
1563 }
1564}
1565
1566fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef {
1568 match schema_type.to_lowercase().as_str() {
1569 "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1570 "type": "string",
1571 "format": "uuid"
1572 })),
1573 "integer" | "int" | "int64" | "i64" => {
1574 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1575 "type": "integer",
1576 "format": "int64"
1577 }))
1578 }
1579 "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1580 "type": "integer",
1581 "format": "int32"
1582 })),
1583 "number" | "float" | "f64" | "f32" => {
1584 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1585 "type": "number"
1586 }))
1587 }
1588 "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1589 "type": "boolean"
1590 })),
1591 _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1592 "type": "string"
1593 })),
1594 }
1595}
1596
1597fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
1606 let lower = name.to_lowercase();
1607
1608 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
1610
1611 if is_uuid {
1612 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1613 "type": "string",
1614 "format": "uuid"
1615 }));
1616 }
1617
1618 let is_integer = lower == "page"
1621 || lower == "limit"
1622 || lower == "offset"
1623 || lower == "count"
1624 || lower.ends_with("_count")
1625 || lower.ends_with("_num")
1626 || lower == "year"
1627 || lower == "month"
1628 || lower == "day"
1629 || lower == "index"
1630 || lower == "position";
1631
1632 if is_integer {
1633 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1634 "type": "integer",
1635 "format": "int64"
1636 }))
1637 } else {
1638 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
1639 }
1640}
1641
1642fn normalize_prefix_for_openapi(prefix: &str) -> String {
1649 if prefix.is_empty() {
1651 return "/".to_string();
1652 }
1653
1654 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
1656
1657 if segments.is_empty() {
1659 return "/".to_string();
1660 }
1661
1662 let mut result = String::with_capacity(prefix.len() + 1);
1664 for segment in segments {
1665 result.push('/');
1666 result.push_str(segment);
1667 }
1668
1669 result
1670}
1671
1672impl Default for RustApi {
1673 fn default() -> Self {
1674 Self::new()
1675 }
1676}
1677
1678#[cfg(feature = "swagger-ui")]
1680fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1681 req.headers()
1682 .get(http::header::AUTHORIZATION)
1683 .and_then(|v| v.to_str().ok())
1684 .map(|auth| auth == expected)
1685 .unwrap_or(false)
1686}
1687
1688#[cfg(feature = "swagger-ui")]
1690fn unauthorized_response() -> crate::Response {
1691 http::Response::builder()
1692 .status(http::StatusCode::UNAUTHORIZED)
1693 .header(
1694 http::header::WWW_AUTHENTICATE,
1695 "Basic realm=\"API Documentation\"",
1696 )
1697 .header(http::header::CONTENT_TYPE, "text/plain")
1698 .body(crate::response::Body::from("Unauthorized"))
1699 .unwrap()
1700}
1701
1702pub struct RustApiConfig {
1704 docs_path: Option<String>,
1705 docs_enabled: bool,
1706 api_title: String,
1707 api_version: String,
1708 api_description: Option<String>,
1709 body_limit: Option<usize>,
1710 layers: LayerStack,
1711}
1712
1713impl Default for RustApiConfig {
1714 fn default() -> Self {
1715 Self::new()
1716 }
1717}
1718
1719impl RustApiConfig {
1720 pub fn new() -> Self {
1721 Self {
1722 docs_path: Some("/docs".to_string()),
1723 docs_enabled: true,
1724 api_title: "RustAPI".to_string(),
1725 api_version: "1.0.0".to_string(),
1726 api_description: None,
1727 body_limit: None,
1728 layers: LayerStack::new(),
1729 }
1730 }
1731
1732 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
1734 self.docs_path = Some(path.into());
1735 self
1736 }
1737
1738 pub fn docs_enabled(mut self, enabled: bool) -> Self {
1740 self.docs_enabled = enabled;
1741 self
1742 }
1743
1744 pub fn openapi_info(
1746 mut self,
1747 title: impl Into<String>,
1748 version: impl Into<String>,
1749 description: Option<impl Into<String>>,
1750 ) -> Self {
1751 self.api_title = title.into();
1752 self.api_version = version.into();
1753 self.api_description = description.map(|d| d.into());
1754 self
1755 }
1756
1757 pub fn body_limit(mut self, limit: usize) -> Self {
1759 self.body_limit = Some(limit);
1760 self
1761 }
1762
1763 pub fn layer<L>(mut self, layer: L) -> Self
1765 where
1766 L: MiddlewareLayer,
1767 {
1768 self.layers.push(Box::new(layer));
1769 self
1770 }
1771
1772 pub fn build(self) -> RustApi {
1774 let mut app = RustApi::new().mount_auto_routes_grouped();
1775
1776 if let Some(limit) = self.body_limit {
1778 app = app.body_limit(limit);
1779 }
1780
1781 app = app.openapi_info(
1782 &self.api_title,
1783 &self.api_version,
1784 self.api_description.as_deref(),
1785 );
1786
1787 #[cfg(feature = "swagger-ui")]
1788 if self.docs_enabled {
1789 if let Some(path) = self.docs_path {
1790 app = app.docs(&path);
1791 }
1792 }
1793
1794 app.layers.extend(self.layers);
1797
1798 app
1799 }
1800
1801 pub async fn run(
1803 self,
1804 addr: impl AsRef<str>,
1805 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1806 self.build().run(addr.as_ref()).await
1807 }
1808}
1809
1810#[cfg(test)]
1811mod tests {
1812 use super::RustApi;
1813 use crate::extract::{FromRequestParts, State};
1814 use crate::path_params::PathParams;
1815 use crate::request::Request;
1816 use crate::router::{get, post, Router};
1817 use bytes::Bytes;
1818 use http::Method;
1819 use proptest::prelude::*;
1820
1821 #[test]
1822 fn state_is_available_via_extractor() {
1823 let app = RustApi::new().state(123u32);
1824 let router = app.into_router();
1825
1826 let req = http::Request::builder()
1827 .method(Method::GET)
1828 .uri("/test")
1829 .body(())
1830 .unwrap();
1831 let (parts, _) = req.into_parts();
1832
1833 let request = Request::new(
1834 parts,
1835 crate::request::BodyVariant::Buffered(Bytes::new()),
1836 router.state_ref(),
1837 PathParams::new(),
1838 );
1839 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
1840 assert_eq!(value, 123u32);
1841 }
1842
1843 #[test]
1844 fn test_path_param_type_inference_integer() {
1845 use super::infer_path_param_schema;
1846
1847 let int_params = [
1849 "page",
1850 "limit",
1851 "offset",
1852 "count",
1853 "item_count",
1854 "year",
1855 "month",
1856 "day",
1857 "index",
1858 "position",
1859 ];
1860
1861 for name in int_params {
1862 let schema = infer_path_param_schema(name);
1863 match schema {
1864 rustapi_openapi::SchemaRef::Inline(v) => {
1865 assert_eq!(
1866 v.get("type").and_then(|v| v.as_str()),
1867 Some("integer"),
1868 "Expected '{}' to be inferred as integer",
1869 name
1870 );
1871 }
1872 _ => panic!("Expected inline schema for '{}'", name),
1873 }
1874 }
1875 }
1876
1877 #[test]
1878 fn test_path_param_type_inference_uuid() {
1879 use super::infer_path_param_schema;
1880
1881 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
1883
1884 for name in uuid_params {
1885 let schema = infer_path_param_schema(name);
1886 match schema {
1887 rustapi_openapi::SchemaRef::Inline(v) => {
1888 assert_eq!(
1889 v.get("type").and_then(|v| v.as_str()),
1890 Some("string"),
1891 "Expected '{}' to be inferred as string",
1892 name
1893 );
1894 assert_eq!(
1895 v.get("format").and_then(|v| v.as_str()),
1896 Some("uuid"),
1897 "Expected '{}' to have uuid format",
1898 name
1899 );
1900 }
1901 _ => panic!("Expected inline schema for '{}'", name),
1902 }
1903 }
1904 }
1905
1906 #[test]
1907 fn test_path_param_type_inference_string() {
1908 use super::infer_path_param_schema;
1909
1910 let string_params = [
1912 "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId",
1913 ];
1914
1915 for name in string_params {
1916 let schema = infer_path_param_schema(name);
1917 match schema {
1918 rustapi_openapi::SchemaRef::Inline(v) => {
1919 assert_eq!(
1920 v.get("type").and_then(|v| v.as_str()),
1921 Some("string"),
1922 "Expected '{}' to be inferred as string",
1923 name
1924 );
1925 assert!(
1926 v.get("format").is_none()
1927 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
1928 "Expected '{}' to NOT have uuid format",
1929 name
1930 );
1931 }
1932 _ => panic!("Expected inline schema for '{}'", name),
1933 }
1934 }
1935 }
1936
1937 #[test]
1938 fn test_schema_type_to_openapi_schema() {
1939 use super::schema_type_to_openapi_schema;
1940
1941 let uuid_schema = schema_type_to_openapi_schema("uuid");
1943 match uuid_schema {
1944 rustapi_openapi::SchemaRef::Inline(v) => {
1945 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
1946 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid"));
1947 }
1948 _ => panic!("Expected inline schema for uuid"),
1949 }
1950
1951 for schema_type in ["integer", "int", "int64", "i64"] {
1953 let schema = schema_type_to_openapi_schema(schema_type);
1954 match schema {
1955 rustapi_openapi::SchemaRef::Inline(v) => {
1956 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
1957 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64"));
1958 }
1959 _ => panic!("Expected inline schema for {}", schema_type),
1960 }
1961 }
1962
1963 let int32_schema = schema_type_to_openapi_schema("int32");
1965 match int32_schema {
1966 rustapi_openapi::SchemaRef::Inline(v) => {
1967 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
1968 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32"));
1969 }
1970 _ => panic!("Expected inline schema for int32"),
1971 }
1972
1973 for schema_type in ["number", "float"] {
1975 let schema = schema_type_to_openapi_schema(schema_type);
1976 match schema {
1977 rustapi_openapi::SchemaRef::Inline(v) => {
1978 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number"));
1979 }
1980 _ => panic!("Expected inline schema for {}", schema_type),
1981 }
1982 }
1983
1984 for schema_type in ["boolean", "bool"] {
1986 let schema = schema_type_to_openapi_schema(schema_type);
1987 match schema {
1988 rustapi_openapi::SchemaRef::Inline(v) => {
1989 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean"));
1990 }
1991 _ => panic!("Expected inline schema for {}", schema_type),
1992 }
1993 }
1994
1995 let string_schema = schema_type_to_openapi_schema("string");
1997 match string_schema {
1998 rustapi_openapi::SchemaRef::Inline(v) => {
1999 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2000 }
2001 _ => panic!("Expected inline schema for string"),
2002 }
2003 }
2004
2005 proptest! {
2012 #![proptest_config(ProptestConfig::with_cases(100))]
2013
2014 #[test]
2019 fn prop_nested_routes_in_openapi_spec(
2020 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2022 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2024 has_param in any::<bool>(),
2025 ) {
2026 async fn handler() -> &'static str { "handler" }
2027
2028 let prefix = format!("/{}", prefix_segments.join("/"));
2030
2031 let mut route_path = format!("/{}", route_segments.join("/"));
2033 if has_param {
2034 route_path.push_str("/{id}");
2035 }
2036
2037 let nested_router = Router::new().route(&route_path, get(handler));
2039 let app = RustApi::new().nest(&prefix, nested_router);
2040
2041 let expected_openapi_path = format!("{}{}", prefix, route_path);
2043
2044 let spec = app.openapi_spec();
2046
2047 prop_assert!(
2049 spec.paths.contains_key(&expected_openapi_path),
2050 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2051 expected_openapi_path,
2052 spec.paths.keys().collect::<Vec<_>>()
2053 );
2054
2055 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2057 prop_assert!(
2058 path_item.get.is_some(),
2059 "GET operation should exist for path '{}'",
2060 expected_openapi_path
2061 );
2062 }
2063
2064 #[test]
2069 fn prop_multiple_methods_preserved_in_openapi(
2070 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2071 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2072 ) {
2073 async fn get_handler() -> &'static str { "get" }
2074 async fn post_handler() -> &'static str { "post" }
2075
2076 let prefix = format!("/{}", prefix_segments.join("/"));
2078 let route_path = format!("/{}", route_segments.join("/"));
2079
2080 let get_route_path = format!("{}/get", route_path);
2083 let post_route_path = format!("{}/post", route_path);
2084 let nested_router = Router::new()
2085 .route(&get_route_path, get(get_handler))
2086 .route(&post_route_path, post(post_handler));
2087 let app = RustApi::new().nest(&prefix, nested_router);
2088
2089 let expected_get_path = format!("{}{}", prefix, get_route_path);
2091 let expected_post_path = format!("{}{}", prefix, post_route_path);
2092
2093 let spec = app.openapi_spec();
2095
2096 prop_assert!(
2098 spec.paths.contains_key(&expected_get_path),
2099 "Expected OpenAPI path '{}' not found",
2100 expected_get_path
2101 );
2102 prop_assert!(
2103 spec.paths.contains_key(&expected_post_path),
2104 "Expected OpenAPI path '{}' not found",
2105 expected_post_path
2106 );
2107
2108 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
2110 prop_assert!(
2111 get_path_item.get.is_some(),
2112 "GET operation should exist for path '{}'",
2113 expected_get_path
2114 );
2115
2116 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
2118 prop_assert!(
2119 post_path_item.post.is_some(),
2120 "POST operation should exist for path '{}'",
2121 expected_post_path
2122 );
2123 }
2124
2125 #[test]
2130 fn prop_path_params_in_openapi_after_nesting(
2131 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2132 param_name in "[a-z][a-z0-9]{0,5}",
2133 ) {
2134 async fn handler() -> &'static str { "handler" }
2135
2136 let prefix = format!("/{}", prefix_segments.join("/"));
2138 let route_path = format!("/{{{}}}", param_name);
2139
2140 let nested_router = Router::new().route(&route_path, get(handler));
2142 let app = RustApi::new().nest(&prefix, nested_router);
2143
2144 let expected_openapi_path = format!("{}{}", prefix, route_path);
2146
2147 let spec = app.openapi_spec();
2149
2150 prop_assert!(
2152 spec.paths.contains_key(&expected_openapi_path),
2153 "Expected OpenAPI path '{}' not found",
2154 expected_openapi_path
2155 );
2156
2157 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2159 let get_op = path_item.get.as_ref().unwrap();
2160
2161 prop_assert!(
2162 !get_op.parameters.is_empty(),
2163 "Operation should have parameters for path '{}'",
2164 expected_openapi_path
2165 );
2166
2167 let params = &get_op.parameters;
2168 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
2169 prop_assert!(
2170 has_param,
2171 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
2172 param_name,
2173 params.iter().map(|p| &p.name).collect::<Vec<_>>()
2174 );
2175 }
2176 }
2177
2178 proptest! {
2186 #![proptest_config(ProptestConfig::with_cases(100))]
2187
2188 #[test]
2193 fn prop_rustapi_nest_delegates_to_router_nest(
2194 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2195 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2196 has_param in any::<bool>(),
2197 ) {
2198 async fn handler() -> &'static str { "handler" }
2199
2200 let prefix = format!("/{}", prefix_segments.join("/"));
2202
2203 let mut route_path = format!("/{}", route_segments.join("/"));
2205 if has_param {
2206 route_path.push_str("/{id}");
2207 }
2208
2209 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2211 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2212
2213 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2215 let rustapi_router = rustapi_app.into_router();
2216
2217 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2219
2220 let rustapi_routes = rustapi_router.registered_routes();
2222 let router_routes = router_app.registered_routes();
2223
2224 prop_assert_eq!(
2225 rustapi_routes.len(),
2226 router_routes.len(),
2227 "RustApi and Router should have same number of routes"
2228 );
2229
2230 for (path, info) in router_routes {
2232 prop_assert!(
2233 rustapi_routes.contains_key(path),
2234 "Route '{}' from Router should exist in RustApi routes",
2235 path
2236 );
2237
2238 let rustapi_info = rustapi_routes.get(path).unwrap();
2239 prop_assert_eq!(
2240 &info.path, &rustapi_info.path,
2241 "Display paths should match for route '{}'",
2242 path
2243 );
2244 prop_assert_eq!(
2245 info.methods.len(), rustapi_info.methods.len(),
2246 "Method count should match for route '{}'",
2247 path
2248 );
2249 }
2250 }
2251
2252 #[test]
2257 fn prop_rustapi_nest_includes_routes_in_openapi(
2258 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2259 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2260 has_param in any::<bool>(),
2261 ) {
2262 async fn handler() -> &'static str { "handler" }
2263
2264 let prefix = format!("/{}", prefix_segments.join("/"));
2266
2267 let mut route_path = format!("/{}", route_segments.join("/"));
2269 if has_param {
2270 route_path.push_str("/{id}");
2271 }
2272
2273 let nested_router = Router::new().route(&route_path, get(handler));
2275 let app = RustApi::new().nest(&prefix, nested_router);
2276
2277 let expected_openapi_path = format!("{}{}", prefix, route_path);
2279
2280 let spec = app.openapi_spec();
2282
2283 prop_assert!(
2285 spec.paths.contains_key(&expected_openapi_path),
2286 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2287 expected_openapi_path,
2288 spec.paths.keys().collect::<Vec<_>>()
2289 );
2290
2291 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2293 prop_assert!(
2294 path_item.get.is_some(),
2295 "GET operation should exist for path '{}'",
2296 expected_openapi_path
2297 );
2298 }
2299
2300 #[test]
2305 fn prop_rustapi_nest_route_matching_identical(
2306 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2307 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2308 param_value in "[a-z0-9]{1,10}",
2309 ) {
2310 use crate::router::RouteMatch;
2311
2312 async fn handler() -> &'static str { "handler" }
2313
2314 let prefix = format!("/{}", prefix_segments.join("/"));
2316 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
2317
2318 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2320 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2321
2322 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2324 let rustapi_router = rustapi_app.into_router();
2325 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2326
2327 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
2329
2330 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
2332 let router_match = router_app.match_route(&full_path, &Method::GET);
2333
2334 match (rustapi_match, router_match) {
2336 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
2337 prop_assert_eq!(
2338 rustapi_params.len(),
2339 router_params.len(),
2340 "Parameter count should match"
2341 );
2342 for (key, value) in &router_params {
2343 prop_assert!(
2344 rustapi_params.contains_key(key),
2345 "RustApi should have parameter '{}'",
2346 key
2347 );
2348 prop_assert_eq!(
2349 rustapi_params.get(key).unwrap(),
2350 value,
2351 "Parameter '{}' value should match",
2352 key
2353 );
2354 }
2355 }
2356 (rustapi_result, router_result) => {
2357 prop_assert!(
2358 false,
2359 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
2360 match rustapi_result {
2361 RouteMatch::Found { .. } => "Found",
2362 RouteMatch::NotFound => "NotFound",
2363 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2364 },
2365 match router_result {
2366 RouteMatch::Found { .. } => "Found",
2367 RouteMatch::NotFound => "NotFound",
2368 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2369 }
2370 );
2371 }
2372 }
2373 }
2374 }
2375
2376 #[test]
2378 fn test_openapi_operations_propagated_during_nesting() {
2379 async fn list_users() -> &'static str {
2380 "list users"
2381 }
2382 async fn get_user() -> &'static str {
2383 "get user"
2384 }
2385 async fn create_user() -> &'static str {
2386 "create user"
2387 }
2388
2389 let users_router = Router::new()
2392 .route("/", get(list_users))
2393 .route("/create", post(create_user))
2394 .route("/{id}", get(get_user));
2395
2396 let app = RustApi::new().nest("/api/v1/users", users_router);
2398
2399 let spec = app.openapi_spec();
2400
2401 assert!(
2403 spec.paths.contains_key("/api/v1/users"),
2404 "Should have /api/v1/users path"
2405 );
2406 let users_path = spec.paths.get("/api/v1/users").unwrap();
2407 assert!(users_path.get.is_some(), "Should have GET operation");
2408
2409 assert!(
2411 spec.paths.contains_key("/api/v1/users/create"),
2412 "Should have /api/v1/users/create path"
2413 );
2414 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
2415 assert!(create_path.post.is_some(), "Should have POST operation");
2416
2417 assert!(
2419 spec.paths.contains_key("/api/v1/users/{id}"),
2420 "Should have /api/v1/users/{{id}} path"
2421 );
2422 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
2423 assert!(
2424 user_path.get.is_some(),
2425 "Should have GET operation for user by id"
2426 );
2427
2428 let get_user_op = user_path.get.as_ref().unwrap();
2430 assert!(!get_user_op.parameters.is_empty(), "Should have parameters");
2431 let params = &get_user_op.parameters;
2432 assert!(
2433 params
2434 .iter()
2435 .any(|p| p.name == "id" && p.location == "path"),
2436 "Should have 'id' path parameter"
2437 );
2438 }
2439
2440 #[test]
2442 fn test_openapi_spec_empty_without_routes() {
2443 let app = RustApi::new();
2444 let spec = app.openapi_spec();
2445
2446 assert!(
2448 spec.paths.is_empty(),
2449 "OpenAPI spec should have no paths without routes"
2450 );
2451 }
2452
2453 #[test]
2458 fn test_rustapi_nest_delegates_to_router_nest() {
2459 use crate::router::RouteMatch;
2460
2461 async fn list_users() -> &'static str {
2462 "list users"
2463 }
2464 async fn get_user() -> &'static str {
2465 "get user"
2466 }
2467 async fn create_user() -> &'static str {
2468 "create user"
2469 }
2470
2471 let users_router = Router::new()
2473 .route("/", get(list_users))
2474 .route("/create", post(create_user))
2475 .route("/{id}", get(get_user));
2476
2477 let app = RustApi::new().nest("/api/v1/users", users_router);
2479 let router = app.into_router();
2480
2481 let routes = router.registered_routes();
2483 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
2484
2485 assert!(
2487 routes.contains_key("/api/v1/users"),
2488 "Should have /api/v1/users route"
2489 );
2490 assert!(
2491 routes.contains_key("/api/v1/users/create"),
2492 "Should have /api/v1/users/create route"
2493 );
2494 assert!(
2495 routes.contains_key("/api/v1/users/:id"),
2496 "Should have /api/v1/users/:id route"
2497 );
2498
2499 match router.match_route("/api/v1/users", &Method::GET) {
2501 RouteMatch::Found { params, .. } => {
2502 assert!(params.is_empty(), "Root route should have no params");
2503 }
2504 _ => panic!("GET /api/v1/users should be found"),
2505 }
2506
2507 match router.match_route("/api/v1/users/create", &Method::POST) {
2508 RouteMatch::Found { params, .. } => {
2509 assert!(params.is_empty(), "Create route should have no params");
2510 }
2511 _ => panic!("POST /api/v1/users/create should be found"),
2512 }
2513
2514 match router.match_route("/api/v1/users/123", &Method::GET) {
2515 RouteMatch::Found { params, .. } => {
2516 assert_eq!(
2517 params.get("id"),
2518 Some(&"123".to_string()),
2519 "Should extract id param"
2520 );
2521 }
2522 _ => panic!("GET /api/v1/users/123 should be found"),
2523 }
2524
2525 match router.match_route("/api/v1/users", &Method::DELETE) {
2527 RouteMatch::MethodNotAllowed { allowed } => {
2528 assert!(allowed.contains(&Method::GET), "Should allow GET");
2529 }
2530 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
2531 }
2532 }
2533
2534 #[test]
2539 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
2540 async fn list_items() -> &'static str {
2541 "list items"
2542 }
2543 async fn get_item() -> &'static str {
2544 "get item"
2545 }
2546
2547 let items_router = Router::new()
2549 .route("/", get(list_items))
2550 .route("/{item_id}", get(get_item));
2551
2552 let app = RustApi::new().nest("/api/items", items_router);
2554
2555 let spec = app.openapi_spec();
2557
2558 assert!(
2560 spec.paths.contains_key("/api/items"),
2561 "Should have /api/items in OpenAPI"
2562 );
2563 assert!(
2564 spec.paths.contains_key("/api/items/{item_id}"),
2565 "Should have /api/items/{{item_id}} in OpenAPI"
2566 );
2567
2568 let list_path = spec.paths.get("/api/items").unwrap();
2570 assert!(
2571 list_path.get.is_some(),
2572 "Should have GET operation for /api/items"
2573 );
2574
2575 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
2576 assert!(
2577 get_path.get.is_some(),
2578 "Should have GET operation for /api/items/{{item_id}}"
2579 );
2580
2581 let get_op = get_path.get.as_ref().unwrap();
2583 assert!(!get_op.parameters.is_empty(), "Should have parameters");
2584 let params = &get_op.parameters;
2585 assert!(
2586 params
2587 .iter()
2588 .any(|p| p.name == "item_id" && p.location == "path"),
2589 "Should have 'item_id' path parameter"
2590 );
2591 }
2592}