1use crate::error::Result;
4use crate::events::LifecycleHooks;
5use crate::interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor};
6use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT};
7use crate::response::IntoResponse;
8use crate::router::{MethodRouter, Router};
9use crate::server::Server;
10use std::collections::BTreeMap;
11use std::future::Future;
12use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
13
14pub struct RustApi {
32 router: Router,
33 openapi_spec: rustapi_openapi::OpenApiSpec,
34 layers: LayerStack,
35 body_limit: Option<usize>,
36 interceptors: InterceptorChain,
37 lifecycle_hooks: LifecycleHooks,
38 hot_reload: bool,
39 #[cfg(feature = "http3")]
40 http3_config: Option<crate::http3::Http3Config>,
41 health_check: Option<crate::health::HealthCheck>,
42 health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
43 status_config: Option<crate::status::StatusConfig>,
44}
45
46#[derive(Debug, Clone)]
54pub struct ProductionDefaultsConfig {
55 service_name: String,
56 version: Option<String>,
57 tracing_level: tracing::Level,
58 health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
59 enable_request_id: bool,
60 enable_tracing: bool,
61 enable_health_endpoints: bool,
62}
63
64impl ProductionDefaultsConfig {
65 pub fn new(service_name: impl Into<String>) -> Self {
67 Self {
68 service_name: service_name.into(),
69 version: None,
70 tracing_level: tracing::Level::INFO,
71 health_endpoint_config: None,
72 enable_request_id: true,
73 enable_tracing: true,
74 enable_health_endpoints: true,
75 }
76 }
77
78 pub fn version(mut self, version: impl Into<String>) -> Self {
80 self.version = Some(version.into());
81 self
82 }
83
84 pub fn tracing_level(mut self, level: tracing::Level) -> Self {
86 self.tracing_level = level;
87 self
88 }
89
90 pub fn health_endpoint_config(mut self, config: crate::health::HealthEndpointConfig) -> Self {
92 self.health_endpoint_config = Some(config);
93 self
94 }
95
96 pub fn request_id(mut self, enabled: bool) -> Self {
98 self.enable_request_id = enabled;
99 self
100 }
101
102 pub fn tracing(mut self, enabled: bool) -> Self {
104 self.enable_tracing = enabled;
105 self
106 }
107
108 pub fn health_endpoints(mut self, enabled: bool) -> Self {
110 self.enable_health_endpoints = enabled;
111 self
112 }
113}
114
115impl RustApi {
116 pub fn new() -> Self {
118 let _ = tracing_subscriber::registry()
120 .with(
121 EnvFilter::try_from_default_env()
122 .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
123 )
124 .with(tracing_subscriber::fmt::layer())
125 .try_init();
126
127 Self {
128 router: Router::new(),
129 openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
130 .register::<rustapi_openapi::ErrorSchema>()
131 .register::<rustapi_openapi::ErrorBodySchema>()
132 .register::<rustapi_openapi::ValidationErrorSchema>()
133 .register::<rustapi_openapi::ValidationErrorBodySchema>()
134 .register::<rustapi_openapi::FieldErrorSchema>(),
135 layers: LayerStack::new(),
136 body_limit: Some(DEFAULT_BODY_LIMIT), interceptors: InterceptorChain::new(),
138 lifecycle_hooks: LifecycleHooks::new(),
139 hot_reload: false,
140 #[cfg(feature = "http3")]
141 http3_config: None,
142 health_check: None,
143 health_endpoint_config: None,
144 status_config: None,
145 }
146 }
147
148 #[cfg(feature = "swagger-ui")]
172 pub fn auto() -> Self {
173 Self::new().mount_auto_routes_grouped().docs("/docs")
175 }
176
177 #[cfg(not(feature = "swagger-ui"))]
182 pub fn auto() -> Self {
183 Self::new().mount_auto_routes_grouped()
184 }
185
186 pub fn config() -> RustApiConfig {
204 RustApiConfig::new()
205 }
206
207 pub fn body_limit(mut self, limit: usize) -> Self {
228 self.body_limit = Some(limit);
229 self
230 }
231
232 pub fn no_body_limit(mut self) -> Self {
245 self.body_limit = None;
246 self
247 }
248
249 pub fn layer<L>(mut self, layer: L) -> Self
269 where
270 L: MiddlewareLayer,
271 {
272 self.layers.push(Box::new(layer));
273 self
274 }
275
276 pub fn request_interceptor<I>(mut self, interceptor: I) -> Self
308 where
309 I: RequestInterceptor,
310 {
311 self.interceptors.add_request_interceptor(interceptor);
312 self
313 }
314
315 pub fn response_interceptor<I>(mut self, interceptor: I) -> Self
347 where
348 I: ResponseInterceptor,
349 {
350 self.interceptors.add_response_interceptor(interceptor);
351 self
352 }
353
354 pub fn state<S>(self, _state: S) -> Self
370 where
371 S: Clone + Send + Sync + 'static,
372 {
373 let state = _state;
375 let mut app = self;
376 app.router = app.router.state(state);
377 app
378 }
379
380 pub fn on_start<F, Fut>(mut self, hook: F) -> Self
397 where
398 F: FnOnce() -> Fut + Send + 'static,
399 Fut: Future<Output = ()> + Send + 'static,
400 {
401 self.lifecycle_hooks
402 .on_start
403 .push(Box::new(move || Box::pin(hook())));
404 self
405 }
406
407 pub fn on_shutdown<F, Fut>(mut self, hook: F) -> Self
424 where
425 F: FnOnce() -> Fut + Send + 'static,
426 Fut: Future<Output = ()> + Send + 'static,
427 {
428 self.lifecycle_hooks
429 .on_shutdown
430 .push(Box::new(move || Box::pin(hook())));
431 self
432 }
433
434 pub fn hot_reload(mut self, enabled: bool) -> Self {
453 self.hot_reload = enabled;
454 self
455 }
456
457 pub fn register_schema<T: rustapi_openapi::schema::RustApiSchema>(mut self) -> Self {
469 self.openapi_spec = self.openapi_spec.register::<T>();
470 self
471 }
472
473 pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
475 self.openapi_spec.info.title = title.to_string();
478 self.openapi_spec.info.version = version.to_string();
479 self.openapi_spec.info.description = description.map(|d| d.to_string());
480 self
481 }
482
483 pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
485 &self.openapi_spec
486 }
487
488 fn mount_auto_routes_grouped(mut self) -> Self {
489 let routes = crate::auto_route::collect_auto_routes();
490 let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();
492
493 for route in routes {
494 let crate::handler::Route {
495 path: route_path,
496 method,
497 handler,
498 operation,
499 component_registrar,
500 ..
501 } = route;
502
503 let method_enum = match method {
504 "GET" => http::Method::GET,
505 "POST" => http::Method::POST,
506 "PUT" => http::Method::PUT,
507 "DELETE" => http::Method::DELETE,
508 "PATCH" => http::Method::PATCH,
509 _ => http::Method::GET,
510 };
511
512 let path = if route_path.starts_with('/') {
513 route_path.to_string()
514 } else {
515 format!("/{}", route_path)
516 };
517
518 let entry = by_path.entry(path).or_default();
519 entry.insert_boxed_with_operation(
520 method_enum,
521 handler,
522 operation,
523 component_registrar,
524 );
525 }
526
527 #[cfg(feature = "tracing")]
528 let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
529 #[cfg(feature = "tracing")]
530 let path_count = by_path.len();
531
532 for (path, method_router) in by_path {
533 self = self.route(&path, method_router);
534 }
535
536 crate::trace_info!(
537 paths = path_count,
538 routes = route_count,
539 "Auto-registered routes"
540 );
541
542 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
544
545 self
546 }
547
548 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
559 for register_components in &method_router.component_registrars {
560 register_components(&mut self.openapi_spec);
561 }
562
563 for (method, op) in &method_router.operations {
565 let mut op = op.clone();
566 add_path_params_to_operation(path, &mut op, &BTreeMap::new());
567 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
568 }
569
570 self.router = self.router.route(path, method_router);
571 self
572 }
573
574 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
576 self.route(P::PATH, method_router)
577 }
578
579 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
583 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
584 self.route(path, method_router)
585 }
586
587 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
605 let method_enum = match route.method {
606 "GET" => http::Method::GET,
607 "POST" => http::Method::POST,
608 "PUT" => http::Method::PUT,
609 "DELETE" => http::Method::DELETE,
610 "PATCH" => http::Method::PATCH,
611 _ => http::Method::GET,
612 };
613
614 (route.component_registrar)(&mut self.openapi_spec);
615
616 let mut op = route.operation;
618 add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
619 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
620
621 self.route_with_method(route.path, method_enum, route.handler)
622 }
623
624 fn route_with_method(
626 self,
627 path: &str,
628 method: http::Method,
629 handler: crate::handler::BoxedHandler,
630 ) -> Self {
631 use crate::router::MethodRouter;
632 let path = if !path.starts_with('/') {
641 format!("/{}", path)
642 } else {
643 path.to_string()
644 };
645
646 let mut handlers = std::collections::HashMap::new();
655 handlers.insert(method, handler);
656
657 let method_router = MethodRouter::from_boxed(handlers);
658 self.route(&path, method_router)
659 }
660
661 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
677 let normalized_prefix = normalize_prefix_for_openapi(prefix);
679
680 for (matchit_path, method_router) in router.method_routers() {
683 for register_components in &method_router.component_registrars {
684 register_components(&mut self.openapi_spec);
685 }
686
687 let display_path = router
689 .registered_routes()
690 .get(matchit_path)
691 .map(|info| info.path.clone())
692 .unwrap_or_else(|| matchit_path.clone());
693
694 let prefixed_path = if display_path == "/" {
696 normalized_prefix.clone()
697 } else {
698 format!("{}{}", normalized_prefix, display_path)
699 };
700
701 for (method, op) in &method_router.operations {
703 let mut op = op.clone();
704 add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
705 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
706 }
707 }
708
709 self.router = self.router.nest(prefix, router);
711 self
712 }
713
714 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
743 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
744 }
745
746 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
763 use crate::router::MethodRouter;
764 use std::collections::HashMap;
765
766 let prefix = config.prefix.clone();
767 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
768
769 let handler: crate::handler::BoxedHandler =
771 std::sync::Arc::new(move |req: crate::Request| {
772 let config = config.clone();
773 let path = req.uri().path().to_string();
774
775 Box::pin(async move {
776 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
777
778 match crate::static_files::StaticFile::serve(relative_path, &config).await {
779 Ok(response) => response,
780 Err(err) => err.into_response(),
781 }
782 })
783 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
784 });
785
786 let mut handlers = HashMap::new();
787 handlers.insert(http::Method::GET, handler);
788 let method_router = MethodRouter::from_boxed(handlers);
789
790 self.route(&catch_all_path, method_router)
791 }
792
793 #[cfg(feature = "compression")]
810 pub fn compression(self) -> Self {
811 self.layer(crate::middleware::CompressionLayer::new())
812 }
813
814 #[cfg(feature = "compression")]
830 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
831 self.layer(crate::middleware::CompressionLayer::with_config(config))
832 }
833
834 #[cfg(feature = "swagger-ui")]
858 pub fn docs(self, path: &str) -> Self {
859 let title = self.openapi_spec.info.title.clone();
860 let version = self.openapi_spec.info.version.clone();
861 let description = self.openapi_spec.info.description.clone();
862
863 self.docs_with_info(path, &title, &version, description.as_deref())
864 }
865
866 #[cfg(feature = "swagger-ui")]
875 pub fn docs_with_info(
876 mut self,
877 path: &str,
878 title: &str,
879 version: &str,
880 description: Option<&str>,
881 ) -> Self {
882 use crate::router::get;
883 self.openapi_spec.info.title = title.to_string();
885 self.openapi_spec.info.version = version.to_string();
886 if let Some(desc) = description {
887 self.openapi_spec.info.description = Some(desc.to_string());
888 }
889
890 let path = path.trim_end_matches('/');
891 let openapi_path = format!("{}/openapi.json", path);
892
893 let spec_value = self.openapi_spec.to_json();
895 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
896 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
898 "{}".to_string()
899 });
900 let openapi_url = openapi_path.clone();
901
902 let spec_handler = move || {
904 let json = spec_json.clone();
905 async move {
906 http::Response::builder()
907 .status(http::StatusCode::OK)
908 .header(http::header::CONTENT_TYPE, "application/json")
909 .body(crate::response::Body::from(json))
910 .unwrap_or_else(|e| {
911 tracing::error!("Failed to build response: {}", e);
912 http::Response::builder()
913 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
914 .body(crate::response::Body::from("Internal Server Error"))
915 .unwrap()
916 })
917 }
918 };
919
920 let docs_handler = move || {
922 let url = openapi_url.clone();
923 async move {
924 let response = rustapi_openapi::swagger_ui_html(&url);
925 response.map(crate::response::Body::Full)
926 }
927 };
928
929 self.route(&openapi_path, get(spec_handler))
930 .route(path, get(docs_handler))
931 }
932
933 #[cfg(feature = "swagger-ui")]
949 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
950 let title = self.openapi_spec.info.title.clone();
951 let version = self.openapi_spec.info.version.clone();
952 let description = self.openapi_spec.info.description.clone();
953
954 self.docs_with_auth_and_info(
955 path,
956 username,
957 password,
958 &title,
959 &version,
960 description.as_deref(),
961 )
962 }
963
964 #[cfg(feature = "swagger-ui")]
980 pub fn docs_with_auth_and_info(
981 mut self,
982 path: &str,
983 username: &str,
984 password: &str,
985 title: &str,
986 version: &str,
987 description: Option<&str>,
988 ) -> Self {
989 use crate::router::MethodRouter;
990 use base64::{engine::general_purpose::STANDARD, Engine};
991 use std::collections::HashMap;
992
993 self.openapi_spec.info.title = title.to_string();
995 self.openapi_spec.info.version = version.to_string();
996 if let Some(desc) = description {
997 self.openapi_spec.info.description = Some(desc.to_string());
998 }
999
1000 let path = path.trim_end_matches('/');
1001 let openapi_path = format!("{}/openapi.json", path);
1002
1003 let credentials = format!("{}:{}", username, password);
1005 let encoded = STANDARD.encode(credentials.as_bytes());
1006 let expected_auth = format!("Basic {}", encoded);
1007
1008 let spec_value = self.openapi_spec.to_json();
1010 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
1011 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
1012 "{}".to_string()
1013 });
1014 let openapi_url = openapi_path.clone();
1015 let expected_auth_spec = expected_auth.clone();
1016 let expected_auth_docs = expected_auth;
1017
1018 let spec_handler: crate::handler::BoxedHandler =
1020 std::sync::Arc::new(move |req: crate::Request| {
1021 let json = spec_json.clone();
1022 let expected = expected_auth_spec.clone();
1023 Box::pin(async move {
1024 if !check_basic_auth(&req, &expected) {
1025 return unauthorized_response();
1026 }
1027 http::Response::builder()
1028 .status(http::StatusCode::OK)
1029 .header(http::header::CONTENT_TYPE, "application/json")
1030 .body(crate::response::Body::from(json))
1031 .unwrap_or_else(|e| {
1032 tracing::error!("Failed to build response: {}", e);
1033 http::Response::builder()
1034 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1035 .body(crate::response::Body::from("Internal Server Error"))
1036 .unwrap()
1037 })
1038 })
1039 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1040 });
1041
1042 let docs_handler: crate::handler::BoxedHandler =
1044 std::sync::Arc::new(move |req: crate::Request| {
1045 let url = openapi_url.clone();
1046 let expected = expected_auth_docs.clone();
1047 Box::pin(async move {
1048 if !check_basic_auth(&req, &expected) {
1049 return unauthorized_response();
1050 }
1051 let response = rustapi_openapi::swagger_ui_html(&url);
1052 response.map(crate::response::Body::Full)
1053 })
1054 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1055 });
1056
1057 let mut spec_handlers = HashMap::new();
1059 spec_handlers.insert(http::Method::GET, spec_handler);
1060 let spec_router = MethodRouter::from_boxed(spec_handlers);
1061
1062 let mut docs_handlers = HashMap::new();
1063 docs_handlers.insert(http::Method::GET, docs_handler);
1064 let docs_router = MethodRouter::from_boxed(docs_handlers);
1065
1066 self.route(&openapi_path, spec_router)
1067 .route(path, docs_router)
1068 }
1069
1070 pub fn status_page(self) -> Self {
1072 self.status_page_with_config(crate::status::StatusConfig::default())
1073 }
1074
1075 pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self {
1077 self.status_config = Some(config);
1078 self
1079 }
1080
1081 pub fn health_endpoints(mut self) -> Self {
1086 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1087 if self.health_check.is_none() {
1088 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1089 }
1090 self
1091 }
1092
1093 pub fn health_endpoints_with_config(
1095 mut self,
1096 config: crate::health::HealthEndpointConfig,
1097 ) -> Self {
1098 self.health_endpoint_config = Some(config);
1099 if self.health_check.is_none() {
1100 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1101 }
1102 self
1103 }
1104
1105 pub fn with_health_check(mut self, health_check: crate::health::HealthCheck) -> Self {
1110 self.health_check = Some(health_check);
1111 if self.health_endpoint_config.is_none() {
1112 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1113 }
1114 self
1115 }
1116
1117 pub fn production_defaults(self, service_name: impl Into<String>) -> Self {
1124 self.production_defaults_with_config(ProductionDefaultsConfig::new(service_name))
1125 }
1126
1127 pub fn production_defaults_with_config(mut self, config: ProductionDefaultsConfig) -> Self {
1129 if config.enable_request_id {
1130 self = self.layer(crate::middleware::RequestIdLayer::new());
1131 }
1132
1133 if config.enable_tracing {
1134 let mut tracing_layer =
1135 crate::middleware::TracingLayer::with_level(config.tracing_level)
1136 .with_field("service", config.service_name.clone())
1137 .with_field("environment", crate::error::get_environment().to_string());
1138
1139 if let Some(version) = &config.version {
1140 tracing_layer = tracing_layer.with_field("version", version.clone());
1141 }
1142
1143 self = self.layer(tracing_layer);
1144 }
1145
1146 if config.enable_health_endpoints {
1147 if self.health_check.is_none() {
1148 let mut builder = crate::health::HealthCheckBuilder::default();
1149 if let Some(version) = &config.version {
1150 builder = builder.version(version.clone());
1151 }
1152 self.health_check = Some(builder.build());
1153 }
1154
1155 if self.health_endpoint_config.is_none() {
1156 self.health_endpoint_config =
1157 Some(config.health_endpoint_config.unwrap_or_default());
1158 }
1159 }
1160
1161 self
1162 }
1163
1164 fn print_hot_reload_banner(&self, addr: &str) {
1166 if !self.hot_reload {
1167 return;
1168 }
1169
1170 std::env::set_var("RUSTAPI_HOT_RELOAD", "1");
1172
1173 let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD")
1174 .map(|v| v == "1")
1175 .unwrap_or(false);
1176
1177 tracing::info!("🔄 Hot-reload mode enabled");
1178
1179 if is_under_watcher {
1180 tracing::info!(" File watcher active — changes will trigger rebuild + restart");
1181 } else {
1182 tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload");
1183 }
1184
1185 tracing::info!(" Listening on http://{addr}");
1186 }
1187
1188 fn apply_health_endpoints(&mut self) {
1190 if let Some(config) = &self.health_endpoint_config {
1191 use crate::router::get;
1192
1193 let health_check = self
1194 .health_check
1195 .clone()
1196 .unwrap_or_else(|| crate::health::HealthCheckBuilder::default().build());
1197
1198 let health_path = config.health_path.clone();
1199 let readiness_path = config.readiness_path.clone();
1200 let liveness_path = config.liveness_path.clone();
1201
1202 let health_handler = {
1203 let health_check = health_check.clone();
1204 move || {
1205 let health_check = health_check.clone();
1206 async move { crate::health::health_response(health_check).await }
1207 }
1208 };
1209
1210 let readiness_handler = {
1211 let health_check = health_check.clone();
1212 move || {
1213 let health_check = health_check.clone();
1214 async move { crate::health::readiness_response(health_check).await }
1215 }
1216 };
1217
1218 let liveness_handler = || async { crate::health::liveness_response().await };
1219
1220 let router = std::mem::take(&mut self.router);
1221 self.router = router
1222 .route(&health_path, get(health_handler))
1223 .route(&readiness_path, get(readiness_handler))
1224 .route(&liveness_path, get(liveness_handler));
1225 }
1226 }
1227
1228 fn apply_status_page(&mut self) {
1229 if let Some(config) = &self.status_config {
1230 let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new());
1231
1232 self.layers
1234 .push(Box::new(crate::status::StatusLayer::new(monitor.clone())));
1235
1236 use crate::router::MethodRouter;
1238 use std::collections::HashMap;
1239
1240 let monitor = monitor.clone();
1241 let config = config.clone();
1242 let path = config.path.clone(); let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| {
1245 let monitor = monitor.clone();
1246 let config = config.clone();
1247 Box::pin(async move {
1248 crate::status::status_handler(monitor, config)
1249 .await
1250 .into_response()
1251 })
1252 });
1253
1254 let mut handlers = HashMap::new();
1255 handlers.insert(http::Method::GET, handler);
1256 let method_router = MethodRouter::from_boxed(handlers);
1257
1258 let router = std::mem::take(&mut self.router);
1260 self.router = router.route(&path, method_router);
1261 }
1262 }
1263
1264 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1275 self.print_hot_reload_banner(addr);
1277
1278 self.apply_health_endpoints();
1280
1281 self.apply_status_page();
1283
1284 if let Some(limit) = self.body_limit {
1286 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1288 }
1289
1290 for hook in self.lifecycle_hooks.on_start {
1292 hook().await;
1293 }
1294
1295 let server = Server::new(self.router, self.layers, self.interceptors);
1296 server.run(addr).await
1297 }
1298
1299 pub async fn run_with_shutdown<F>(
1301 mut self,
1302 addr: impl AsRef<str>,
1303 signal: F,
1304 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1305 where
1306 F: std::future::Future<Output = ()> + Send + 'static,
1307 {
1308 self.print_hot_reload_banner(addr.as_ref());
1310
1311 self.apply_health_endpoints();
1313
1314 self.apply_status_page();
1316
1317 if let Some(limit) = self.body_limit {
1318 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1319 }
1320
1321 for hook in self.lifecycle_hooks.on_start {
1323 hook().await;
1324 }
1325
1326 let shutdown_hooks = self.lifecycle_hooks.on_shutdown;
1328 let wrapped_signal = async move {
1329 signal.await;
1330 for hook in shutdown_hooks {
1332 hook().await;
1333 }
1334 };
1335
1336 let server = Server::new(self.router, self.layers, self.interceptors);
1337 server
1338 .run_with_shutdown(addr.as_ref(), wrapped_signal)
1339 .await
1340 }
1341
1342 pub fn into_router(self) -> Router {
1344 self.router
1345 }
1346
1347 pub fn layers(&self) -> &LayerStack {
1349 &self.layers
1350 }
1351
1352 pub fn interceptors(&self) -> &InterceptorChain {
1354 &self.interceptors
1355 }
1356
1357 #[cfg(feature = "http3")]
1371 pub async fn run_http3(
1372 mut self,
1373 config: crate::http3::Http3Config,
1374 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1375 use std::sync::Arc;
1376
1377 self.apply_health_endpoints();
1379
1380 self.apply_status_page();
1382
1383 if let Some(limit) = self.body_limit {
1385 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1386 }
1387
1388 let server = crate::http3::Http3Server::new(
1389 &config,
1390 Arc::new(self.router),
1391 Arc::new(self.layers),
1392 Arc::new(self.interceptors),
1393 )
1394 .await?;
1395
1396 server.run().await
1397 }
1398
1399 #[cfg(feature = "http3-dev")]
1413 pub async fn run_http3_dev(
1414 mut self,
1415 addr: &str,
1416 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1417 use std::sync::Arc;
1418
1419 self.apply_health_endpoints();
1421
1422 self.apply_status_page();
1424
1425 if let Some(limit) = self.body_limit {
1427 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1428 }
1429
1430 let server = crate::http3::Http3Server::new_with_self_signed(
1431 addr,
1432 Arc::new(self.router),
1433 Arc::new(self.layers),
1434 Arc::new(self.interceptors),
1435 )
1436 .await?;
1437
1438 server.run().await
1439 }
1440
1441 #[cfg(feature = "http3")]
1452 pub fn with_http3(mut self, cert_path: impl Into<String>, key_path: impl Into<String>) -> Self {
1453 self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path));
1454 self
1455 }
1456
1457 #[cfg(feature = "http3")]
1472 pub async fn run_dual_stack(
1473 mut self,
1474 http_addr: &str,
1475 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1476 use std::sync::Arc;
1477
1478 let mut config = self
1479 .http3_config
1480 .take()
1481 .ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
1482
1483 let http_socket: std::net::SocketAddr = http_addr.parse()?;
1484 config.bind_addr = if http_socket.ip().is_ipv6() {
1485 format!("[{}]", http_socket.ip())
1486 } else {
1487 http_socket.ip().to_string()
1488 };
1489 config.port = http_socket.port();
1490 let http_addr = http_socket.to_string();
1491
1492 self.apply_health_endpoints();
1494
1495 self.apply_status_page();
1497
1498 if let Some(limit) = self.body_limit {
1500 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1501 }
1502
1503 let router = Arc::new(self.router);
1504 let layers = Arc::new(self.layers);
1505 let interceptors = Arc::new(self.interceptors);
1506
1507 let http1_server =
1508 Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1509 let http3_server =
1510 crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1511
1512 tracing::info!(
1513 http1_addr = %http_addr,
1514 http3_addr = %config.socket_addr(),
1515 "Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1516 );
1517
1518 tokio::try_join!(
1519 http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1520 http3_server.run_with_shutdown(std::future::pending::<()>()),
1521 )?;
1522
1523 Ok(())
1524 }
1525}
1526
1527fn add_path_params_to_operation(
1528 path: &str,
1529 op: &mut rustapi_openapi::Operation,
1530 param_schemas: &BTreeMap<String, String>,
1531) {
1532 let mut params: Vec<String> = Vec::new();
1533 let mut in_brace = false;
1534 let mut current = String::new();
1535
1536 for ch in path.chars() {
1537 match ch {
1538 '{' => {
1539 in_brace = true;
1540 current.clear();
1541 }
1542 '}' => {
1543 if in_brace {
1544 in_brace = false;
1545 if !current.is_empty() {
1546 params.push(current.clone());
1547 }
1548 }
1549 }
1550 _ => {
1551 if in_brace {
1552 current.push(ch);
1553 }
1554 }
1555 }
1556 }
1557
1558 if params.is_empty() {
1559 return;
1560 }
1561
1562 let op_params = &mut op.parameters;
1563
1564 for name in params {
1565 let already = op_params
1566 .iter()
1567 .any(|p| p.location == "path" && p.name == name);
1568 if already {
1569 continue;
1570 }
1571
1572 let schema = if let Some(schema_type) = param_schemas.get(&name) {
1574 schema_type_to_openapi_schema(schema_type)
1575 } else {
1576 infer_path_param_schema(&name)
1577 };
1578
1579 op_params.push(rustapi_openapi::Parameter {
1580 name,
1581 location: "path".to_string(),
1582 required: true,
1583 description: None,
1584 deprecated: None,
1585 schema: Some(schema),
1586 });
1587 }
1588}
1589
1590fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef {
1592 match schema_type.to_lowercase().as_str() {
1593 "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1594 "type": "string",
1595 "format": "uuid"
1596 })),
1597 "integer" | "int" | "int64" | "i64" => {
1598 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1599 "type": "integer",
1600 "format": "int64"
1601 }))
1602 }
1603 "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1604 "type": "integer",
1605 "format": "int32"
1606 })),
1607 "number" | "float" | "f64" | "f32" => {
1608 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1609 "type": "number"
1610 }))
1611 }
1612 "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1613 "type": "boolean"
1614 })),
1615 _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1616 "type": "string"
1617 })),
1618 }
1619}
1620
1621fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
1630 let lower = name.to_lowercase();
1631
1632 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
1634
1635 if is_uuid {
1636 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1637 "type": "string",
1638 "format": "uuid"
1639 }));
1640 }
1641
1642 let is_integer = lower == "page"
1645 || lower == "limit"
1646 || lower == "offset"
1647 || lower == "count"
1648 || lower.ends_with("_count")
1649 || lower.ends_with("_num")
1650 || lower == "year"
1651 || lower == "month"
1652 || lower == "day"
1653 || lower == "index"
1654 || lower == "position";
1655
1656 if is_integer {
1657 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1658 "type": "integer",
1659 "format": "int64"
1660 }))
1661 } else {
1662 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
1663 }
1664}
1665
1666fn normalize_prefix_for_openapi(prefix: &str) -> String {
1673 if prefix.is_empty() {
1675 return "/".to_string();
1676 }
1677
1678 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
1680
1681 if segments.is_empty() {
1683 return "/".to_string();
1684 }
1685
1686 let mut result = String::with_capacity(prefix.len() + 1);
1688 for segment in segments {
1689 result.push('/');
1690 result.push_str(segment);
1691 }
1692
1693 result
1694}
1695
1696impl Default for RustApi {
1697 fn default() -> Self {
1698 Self::new()
1699 }
1700}
1701
1702#[cfg(feature = "swagger-ui")]
1704fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1705 req.headers()
1706 .get(http::header::AUTHORIZATION)
1707 .and_then(|v| v.to_str().ok())
1708 .map(|auth| auth == expected)
1709 .unwrap_or(false)
1710}
1711
1712#[cfg(feature = "swagger-ui")]
1714fn unauthorized_response() -> crate::Response {
1715 http::Response::builder()
1716 .status(http::StatusCode::UNAUTHORIZED)
1717 .header(
1718 http::header::WWW_AUTHENTICATE,
1719 "Basic realm=\"API Documentation\"",
1720 )
1721 .header(http::header::CONTENT_TYPE, "text/plain")
1722 .body(crate::response::Body::from("Unauthorized"))
1723 .unwrap()
1724}
1725
1726pub struct RustApiConfig {
1728 docs_path: Option<String>,
1729 docs_enabled: bool,
1730 api_title: String,
1731 api_version: String,
1732 api_description: Option<String>,
1733 body_limit: Option<usize>,
1734 layers: LayerStack,
1735}
1736
1737impl Default for RustApiConfig {
1738 fn default() -> Self {
1739 Self::new()
1740 }
1741}
1742
1743impl RustApiConfig {
1744 pub fn new() -> Self {
1745 Self {
1746 docs_path: Some("/docs".to_string()),
1747 docs_enabled: true,
1748 api_title: "RustAPI".to_string(),
1749 api_version: "1.0.0".to_string(),
1750 api_description: None,
1751 body_limit: None,
1752 layers: LayerStack::new(),
1753 }
1754 }
1755
1756 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
1758 self.docs_path = Some(path.into());
1759 self
1760 }
1761
1762 pub fn docs_enabled(mut self, enabled: bool) -> Self {
1764 self.docs_enabled = enabled;
1765 self
1766 }
1767
1768 pub fn openapi_info(
1770 mut self,
1771 title: impl Into<String>,
1772 version: impl Into<String>,
1773 description: Option<impl Into<String>>,
1774 ) -> Self {
1775 self.api_title = title.into();
1776 self.api_version = version.into();
1777 self.api_description = description.map(|d| d.into());
1778 self
1779 }
1780
1781 pub fn body_limit(mut self, limit: usize) -> Self {
1783 self.body_limit = Some(limit);
1784 self
1785 }
1786
1787 pub fn layer<L>(mut self, layer: L) -> Self
1789 where
1790 L: MiddlewareLayer,
1791 {
1792 self.layers.push(Box::new(layer));
1793 self
1794 }
1795
1796 pub fn build(self) -> RustApi {
1798 let mut app = RustApi::new().mount_auto_routes_grouped();
1799
1800 if let Some(limit) = self.body_limit {
1802 app = app.body_limit(limit);
1803 }
1804
1805 app = app.openapi_info(
1806 &self.api_title,
1807 &self.api_version,
1808 self.api_description.as_deref(),
1809 );
1810
1811 #[cfg(feature = "swagger-ui")]
1812 if self.docs_enabled {
1813 if let Some(path) = self.docs_path {
1814 app = app.docs(&path);
1815 }
1816 }
1817
1818 app.layers.extend(self.layers);
1821
1822 app
1823 }
1824
1825 pub async fn run(
1827 self,
1828 addr: impl AsRef<str>,
1829 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1830 self.build().run(addr.as_ref()).await
1831 }
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836 use super::RustApi;
1837 use crate::extract::{FromRequestParts, State};
1838 use crate::path_params::PathParams;
1839 use crate::request::Request;
1840 use crate::router::{get, post, Router};
1841 use bytes::Bytes;
1842 use http::Method;
1843 use proptest::prelude::*;
1844
1845 #[test]
1846 fn state_is_available_via_extractor() {
1847 let app = RustApi::new().state(123u32);
1848 let router = app.into_router();
1849
1850 let req = http::Request::builder()
1851 .method(Method::GET)
1852 .uri("/test")
1853 .body(())
1854 .unwrap();
1855 let (parts, _) = req.into_parts();
1856
1857 let request = Request::new(
1858 parts,
1859 crate::request::BodyVariant::Buffered(Bytes::new()),
1860 router.state_ref(),
1861 PathParams::new(),
1862 );
1863 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
1864 assert_eq!(value, 123u32);
1865 }
1866
1867 #[test]
1868 fn test_path_param_type_inference_integer() {
1869 use super::infer_path_param_schema;
1870
1871 let int_params = [
1873 "page",
1874 "limit",
1875 "offset",
1876 "count",
1877 "item_count",
1878 "year",
1879 "month",
1880 "day",
1881 "index",
1882 "position",
1883 ];
1884
1885 for name in int_params {
1886 let schema = infer_path_param_schema(name);
1887 match schema {
1888 rustapi_openapi::SchemaRef::Inline(v) => {
1889 assert_eq!(
1890 v.get("type").and_then(|v| v.as_str()),
1891 Some("integer"),
1892 "Expected '{}' to be inferred as integer",
1893 name
1894 );
1895 }
1896 _ => panic!("Expected inline schema for '{}'", name),
1897 }
1898 }
1899 }
1900
1901 #[test]
1902 fn test_path_param_type_inference_uuid() {
1903 use super::infer_path_param_schema;
1904
1905 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
1907
1908 for name in uuid_params {
1909 let schema = infer_path_param_schema(name);
1910 match schema {
1911 rustapi_openapi::SchemaRef::Inline(v) => {
1912 assert_eq!(
1913 v.get("type").and_then(|v| v.as_str()),
1914 Some("string"),
1915 "Expected '{}' to be inferred as string",
1916 name
1917 );
1918 assert_eq!(
1919 v.get("format").and_then(|v| v.as_str()),
1920 Some("uuid"),
1921 "Expected '{}' to have uuid format",
1922 name
1923 );
1924 }
1925 _ => panic!("Expected inline schema for '{}'", name),
1926 }
1927 }
1928 }
1929
1930 #[test]
1931 fn test_path_param_type_inference_string() {
1932 use super::infer_path_param_schema;
1933
1934 let string_params = [
1936 "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId",
1937 ];
1938
1939 for name in string_params {
1940 let schema = infer_path_param_schema(name);
1941 match schema {
1942 rustapi_openapi::SchemaRef::Inline(v) => {
1943 assert_eq!(
1944 v.get("type").and_then(|v| v.as_str()),
1945 Some("string"),
1946 "Expected '{}' to be inferred as string",
1947 name
1948 );
1949 assert!(
1950 v.get("format").is_none()
1951 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
1952 "Expected '{}' to NOT have uuid format",
1953 name
1954 );
1955 }
1956 _ => panic!("Expected inline schema for '{}'", name),
1957 }
1958 }
1959 }
1960
1961 #[test]
1962 fn test_schema_type_to_openapi_schema() {
1963 use super::schema_type_to_openapi_schema;
1964
1965 let uuid_schema = schema_type_to_openapi_schema("uuid");
1967 match uuid_schema {
1968 rustapi_openapi::SchemaRef::Inline(v) => {
1969 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
1970 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid"));
1971 }
1972 _ => panic!("Expected inline schema for uuid"),
1973 }
1974
1975 for schema_type in ["integer", "int", "int64", "i64"] {
1977 let schema = schema_type_to_openapi_schema(schema_type);
1978 match schema {
1979 rustapi_openapi::SchemaRef::Inline(v) => {
1980 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
1981 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64"));
1982 }
1983 _ => panic!("Expected inline schema for {}", schema_type),
1984 }
1985 }
1986
1987 let int32_schema = schema_type_to_openapi_schema("int32");
1989 match int32_schema {
1990 rustapi_openapi::SchemaRef::Inline(v) => {
1991 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
1992 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32"));
1993 }
1994 _ => panic!("Expected inline schema for int32"),
1995 }
1996
1997 for schema_type in ["number", "float"] {
1999 let schema = schema_type_to_openapi_schema(schema_type);
2000 match schema {
2001 rustapi_openapi::SchemaRef::Inline(v) => {
2002 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number"));
2003 }
2004 _ => panic!("Expected inline schema for {}", schema_type),
2005 }
2006 }
2007
2008 for schema_type in ["boolean", "bool"] {
2010 let schema = schema_type_to_openapi_schema(schema_type);
2011 match schema {
2012 rustapi_openapi::SchemaRef::Inline(v) => {
2013 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean"));
2014 }
2015 _ => panic!("Expected inline schema for {}", schema_type),
2016 }
2017 }
2018
2019 let string_schema = schema_type_to_openapi_schema("string");
2021 match string_schema {
2022 rustapi_openapi::SchemaRef::Inline(v) => {
2023 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2024 }
2025 _ => panic!("Expected inline schema for string"),
2026 }
2027 }
2028
2029 proptest! {
2036 #![proptest_config(ProptestConfig::with_cases(100))]
2037
2038 #[test]
2043 fn prop_nested_routes_in_openapi_spec(
2044 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2046 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2048 has_param in any::<bool>(),
2049 ) {
2050 async fn handler() -> &'static str { "handler" }
2051
2052 let prefix = format!("/{}", prefix_segments.join("/"));
2054
2055 let mut route_path = format!("/{}", route_segments.join("/"));
2057 if has_param {
2058 route_path.push_str("/{id}");
2059 }
2060
2061 let nested_router = Router::new().route(&route_path, get(handler));
2063 let app = RustApi::new().nest(&prefix, nested_router);
2064
2065 let expected_openapi_path = format!("{}{}", prefix, route_path);
2067
2068 let spec = app.openapi_spec();
2070
2071 prop_assert!(
2073 spec.paths.contains_key(&expected_openapi_path),
2074 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2075 expected_openapi_path,
2076 spec.paths.keys().collect::<Vec<_>>()
2077 );
2078
2079 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2081 prop_assert!(
2082 path_item.get.is_some(),
2083 "GET operation should exist for path '{}'",
2084 expected_openapi_path
2085 );
2086 }
2087
2088 #[test]
2093 fn prop_multiple_methods_preserved_in_openapi(
2094 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2095 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2096 ) {
2097 async fn get_handler() -> &'static str { "get" }
2098 async fn post_handler() -> &'static str { "post" }
2099
2100 let prefix = format!("/{}", prefix_segments.join("/"));
2102 let route_path = format!("/{}", route_segments.join("/"));
2103
2104 let get_route_path = format!("{}/get", route_path);
2107 let post_route_path = format!("{}/post", route_path);
2108 let nested_router = Router::new()
2109 .route(&get_route_path, get(get_handler))
2110 .route(&post_route_path, post(post_handler));
2111 let app = RustApi::new().nest(&prefix, nested_router);
2112
2113 let expected_get_path = format!("{}{}", prefix, get_route_path);
2115 let expected_post_path = format!("{}{}", prefix, post_route_path);
2116
2117 let spec = app.openapi_spec();
2119
2120 prop_assert!(
2122 spec.paths.contains_key(&expected_get_path),
2123 "Expected OpenAPI path '{}' not found",
2124 expected_get_path
2125 );
2126 prop_assert!(
2127 spec.paths.contains_key(&expected_post_path),
2128 "Expected OpenAPI path '{}' not found",
2129 expected_post_path
2130 );
2131
2132 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
2134 prop_assert!(
2135 get_path_item.get.is_some(),
2136 "GET operation should exist for path '{}'",
2137 expected_get_path
2138 );
2139
2140 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
2142 prop_assert!(
2143 post_path_item.post.is_some(),
2144 "POST operation should exist for path '{}'",
2145 expected_post_path
2146 );
2147 }
2148
2149 #[test]
2154 fn prop_path_params_in_openapi_after_nesting(
2155 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2156 param_name in "[a-z][a-z0-9]{0,5}",
2157 ) {
2158 async fn handler() -> &'static str { "handler" }
2159
2160 let prefix = format!("/{}", prefix_segments.join("/"));
2162 let route_path = format!("/{{{}}}", param_name);
2163
2164 let nested_router = Router::new().route(&route_path, get(handler));
2166 let app = RustApi::new().nest(&prefix, nested_router);
2167
2168 let expected_openapi_path = format!("{}{}", prefix, route_path);
2170
2171 let spec = app.openapi_spec();
2173
2174 prop_assert!(
2176 spec.paths.contains_key(&expected_openapi_path),
2177 "Expected OpenAPI path '{}' not found",
2178 expected_openapi_path
2179 );
2180
2181 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2183 let get_op = path_item.get.as_ref().unwrap();
2184
2185 prop_assert!(
2186 !get_op.parameters.is_empty(),
2187 "Operation should have parameters for path '{}'",
2188 expected_openapi_path
2189 );
2190
2191 let params = &get_op.parameters;
2192 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
2193 prop_assert!(
2194 has_param,
2195 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
2196 param_name,
2197 params.iter().map(|p| &p.name).collect::<Vec<_>>()
2198 );
2199 }
2200 }
2201
2202 proptest! {
2210 #![proptest_config(ProptestConfig::with_cases(100))]
2211
2212 #[test]
2217 fn prop_rustapi_nest_delegates_to_router_nest(
2218 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2219 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2220 has_param in any::<bool>(),
2221 ) {
2222 async fn handler() -> &'static str { "handler" }
2223
2224 let prefix = format!("/{}", prefix_segments.join("/"));
2226
2227 let mut route_path = format!("/{}", route_segments.join("/"));
2229 if has_param {
2230 route_path.push_str("/{id}");
2231 }
2232
2233 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2235 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2236
2237 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2239 let rustapi_router = rustapi_app.into_router();
2240
2241 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2243
2244 let rustapi_routes = rustapi_router.registered_routes();
2246 let router_routes = router_app.registered_routes();
2247
2248 prop_assert_eq!(
2249 rustapi_routes.len(),
2250 router_routes.len(),
2251 "RustApi and Router should have same number of routes"
2252 );
2253
2254 for (path, info) in router_routes {
2256 prop_assert!(
2257 rustapi_routes.contains_key(path),
2258 "Route '{}' from Router should exist in RustApi routes",
2259 path
2260 );
2261
2262 let rustapi_info = rustapi_routes.get(path).unwrap();
2263 prop_assert_eq!(
2264 &info.path, &rustapi_info.path,
2265 "Display paths should match for route '{}'",
2266 path
2267 );
2268 prop_assert_eq!(
2269 info.methods.len(), rustapi_info.methods.len(),
2270 "Method count should match for route '{}'",
2271 path
2272 );
2273 }
2274 }
2275
2276 #[test]
2281 fn prop_rustapi_nest_includes_routes_in_openapi(
2282 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2283 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2284 has_param in any::<bool>(),
2285 ) {
2286 async fn handler() -> &'static str { "handler" }
2287
2288 let prefix = format!("/{}", prefix_segments.join("/"));
2290
2291 let mut route_path = format!("/{}", route_segments.join("/"));
2293 if has_param {
2294 route_path.push_str("/{id}");
2295 }
2296
2297 let nested_router = Router::new().route(&route_path, get(handler));
2299 let app = RustApi::new().nest(&prefix, nested_router);
2300
2301 let expected_openapi_path = format!("{}{}", prefix, route_path);
2303
2304 let spec = app.openapi_spec();
2306
2307 prop_assert!(
2309 spec.paths.contains_key(&expected_openapi_path),
2310 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2311 expected_openapi_path,
2312 spec.paths.keys().collect::<Vec<_>>()
2313 );
2314
2315 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2317 prop_assert!(
2318 path_item.get.is_some(),
2319 "GET operation should exist for path '{}'",
2320 expected_openapi_path
2321 );
2322 }
2323
2324 #[test]
2329 fn prop_rustapi_nest_route_matching_identical(
2330 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2331 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2332 param_value in "[a-z0-9]{1,10}",
2333 ) {
2334 use crate::router::RouteMatch;
2335
2336 async fn handler() -> &'static str { "handler" }
2337
2338 let prefix = format!("/{}", prefix_segments.join("/"));
2340 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
2341
2342 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2344 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2345
2346 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2348 let rustapi_router = rustapi_app.into_router();
2349 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2350
2351 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
2353
2354 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
2356 let router_match = router_app.match_route(&full_path, &Method::GET);
2357
2358 match (rustapi_match, router_match) {
2360 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
2361 prop_assert_eq!(
2362 rustapi_params.len(),
2363 router_params.len(),
2364 "Parameter count should match"
2365 );
2366 for (key, value) in &router_params {
2367 prop_assert!(
2368 rustapi_params.contains_key(key),
2369 "RustApi should have parameter '{}'",
2370 key
2371 );
2372 prop_assert_eq!(
2373 rustapi_params.get(key).unwrap(),
2374 value,
2375 "Parameter '{}' value should match",
2376 key
2377 );
2378 }
2379 }
2380 (rustapi_result, router_result) => {
2381 prop_assert!(
2382 false,
2383 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
2384 match rustapi_result {
2385 RouteMatch::Found { .. } => "Found",
2386 RouteMatch::NotFound => "NotFound",
2387 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2388 },
2389 match router_result {
2390 RouteMatch::Found { .. } => "Found",
2391 RouteMatch::NotFound => "NotFound",
2392 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2393 }
2394 );
2395 }
2396 }
2397 }
2398 }
2399
2400 #[test]
2402 fn test_openapi_operations_propagated_during_nesting() {
2403 async fn list_users() -> &'static str {
2404 "list users"
2405 }
2406 async fn get_user() -> &'static str {
2407 "get user"
2408 }
2409 async fn create_user() -> &'static str {
2410 "create user"
2411 }
2412
2413 let users_router = Router::new()
2416 .route("/", get(list_users))
2417 .route("/create", post(create_user))
2418 .route("/{id}", get(get_user));
2419
2420 let app = RustApi::new().nest("/api/v1/users", users_router);
2422
2423 let spec = app.openapi_spec();
2424
2425 assert!(
2427 spec.paths.contains_key("/api/v1/users"),
2428 "Should have /api/v1/users path"
2429 );
2430 let users_path = spec.paths.get("/api/v1/users").unwrap();
2431 assert!(users_path.get.is_some(), "Should have GET operation");
2432
2433 assert!(
2435 spec.paths.contains_key("/api/v1/users/create"),
2436 "Should have /api/v1/users/create path"
2437 );
2438 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
2439 assert!(create_path.post.is_some(), "Should have POST operation");
2440
2441 assert!(
2443 spec.paths.contains_key("/api/v1/users/{id}"),
2444 "Should have /api/v1/users/{{id}} path"
2445 );
2446 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
2447 assert!(
2448 user_path.get.is_some(),
2449 "Should have GET operation for user by id"
2450 );
2451
2452 let get_user_op = user_path.get.as_ref().unwrap();
2454 assert!(!get_user_op.parameters.is_empty(), "Should have parameters");
2455 let params = &get_user_op.parameters;
2456 assert!(
2457 params
2458 .iter()
2459 .any(|p| p.name == "id" && p.location == "path"),
2460 "Should have 'id' path parameter"
2461 );
2462 }
2463
2464 #[test]
2466 fn test_openapi_spec_empty_without_routes() {
2467 let app = RustApi::new();
2468 let spec = app.openapi_spec();
2469
2470 assert!(
2472 spec.paths.is_empty(),
2473 "OpenAPI spec should have no paths without routes"
2474 );
2475 }
2476
2477 #[test]
2482 fn test_rustapi_nest_delegates_to_router_nest() {
2483 use crate::router::RouteMatch;
2484
2485 async fn list_users() -> &'static str {
2486 "list users"
2487 }
2488 async fn get_user() -> &'static str {
2489 "get user"
2490 }
2491 async fn create_user() -> &'static str {
2492 "create user"
2493 }
2494
2495 let users_router = Router::new()
2497 .route("/", get(list_users))
2498 .route("/create", post(create_user))
2499 .route("/{id}", get(get_user));
2500
2501 let app = RustApi::new().nest("/api/v1/users", users_router);
2503 let router = app.into_router();
2504
2505 let routes = router.registered_routes();
2507 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
2508
2509 assert!(
2511 routes.contains_key("/api/v1/users"),
2512 "Should have /api/v1/users route"
2513 );
2514 assert!(
2515 routes.contains_key("/api/v1/users/create"),
2516 "Should have /api/v1/users/create route"
2517 );
2518 assert!(
2519 routes.contains_key("/api/v1/users/:id"),
2520 "Should have /api/v1/users/:id route"
2521 );
2522
2523 match router.match_route("/api/v1/users", &Method::GET) {
2525 RouteMatch::Found { params, .. } => {
2526 assert!(params.is_empty(), "Root route should have no params");
2527 }
2528 _ => panic!("GET /api/v1/users should be found"),
2529 }
2530
2531 match router.match_route("/api/v1/users/create", &Method::POST) {
2532 RouteMatch::Found { params, .. } => {
2533 assert!(params.is_empty(), "Create route should have no params");
2534 }
2535 _ => panic!("POST /api/v1/users/create should be found"),
2536 }
2537
2538 match router.match_route("/api/v1/users/123", &Method::GET) {
2539 RouteMatch::Found { params, .. } => {
2540 assert_eq!(
2541 params.get("id"),
2542 Some(&"123".to_string()),
2543 "Should extract id param"
2544 );
2545 }
2546 _ => panic!("GET /api/v1/users/123 should be found"),
2547 }
2548
2549 match router.match_route("/api/v1/users", &Method::DELETE) {
2551 RouteMatch::MethodNotAllowed { allowed } => {
2552 assert!(allowed.contains(&Method::GET), "Should allow GET");
2553 }
2554 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
2555 }
2556 }
2557
2558 #[test]
2563 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
2564 async fn list_items() -> &'static str {
2565 "list items"
2566 }
2567 async fn get_item() -> &'static str {
2568 "get item"
2569 }
2570
2571 let items_router = Router::new()
2573 .route("/", get(list_items))
2574 .route("/{item_id}", get(get_item));
2575
2576 let app = RustApi::new().nest("/api/items", items_router);
2578
2579 let spec = app.openapi_spec();
2581
2582 assert!(
2584 spec.paths.contains_key("/api/items"),
2585 "Should have /api/items in OpenAPI"
2586 );
2587 assert!(
2588 spec.paths.contains_key("/api/items/{item_id}"),
2589 "Should have /api/items/{{item_id}} in OpenAPI"
2590 );
2591
2592 let list_path = spec.paths.get("/api/items").unwrap();
2594 assert!(
2595 list_path.get.is_some(),
2596 "Should have GET operation for /api/items"
2597 );
2598
2599 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
2600 assert!(
2601 get_path.get.is_some(),
2602 "Should have GET operation for /api/items/{{item_id}}"
2603 );
2604
2605 let get_op = get_path.get.as_ref().unwrap();
2607 assert!(!get_op.parameters.is_empty(), "Should have parameters");
2608 let params = &get_op.parameters;
2609 assert!(
2610 params
2611 .iter()
2612 .any(|p| p.name == "item_id" && p.location == "path"),
2613 "Should have 'item_id' path parameter"
2614 );
2615 }
2616}