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;
11#[cfg(feature = "dashboard")]
12use std::collections::BTreeSet;
13use std::future::Future;
14use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
15
16pub struct RustApi {
34 router: Router,
35 openapi_spec: rustapi_openapi::OpenApiSpec,
36 layers: LayerStack,
37 body_limit: Option<usize>,
38 interceptors: InterceptorChain,
39 lifecycle_hooks: LifecycleHooks,
40 hot_reload: bool,
41 #[cfg(feature = "http3")]
42 http3_config: Option<crate::http3::Http3Config>,
43 health_check: Option<crate::health::HealthCheck>,
44 health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
45 status_config: Option<crate::status::StatusConfig>,
46 #[cfg(feature = "dashboard")]
47 dashboard_config: Option<crate::dashboard::DashboardConfig>,
48}
49
50#[derive(Debug, Clone)]
58pub struct ProductionDefaultsConfig {
59 service_name: String,
60 version: Option<String>,
61 tracing_level: tracing::Level,
62 health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
63 enable_request_id: bool,
64 enable_tracing: bool,
65 enable_health_endpoints: bool,
66}
67
68impl ProductionDefaultsConfig {
69 pub fn new(service_name: impl Into<String>) -> Self {
71 Self {
72 service_name: service_name.into(),
73 version: None,
74 tracing_level: tracing::Level::INFO,
75 health_endpoint_config: None,
76 enable_request_id: true,
77 enable_tracing: true,
78 enable_health_endpoints: true,
79 }
80 }
81
82 pub fn version(mut self, version: impl Into<String>) -> Self {
84 self.version = Some(version.into());
85 self
86 }
87
88 pub fn tracing_level(mut self, level: tracing::Level) -> Self {
90 self.tracing_level = level;
91 self
92 }
93
94 pub fn health_endpoint_config(mut self, config: crate::health::HealthEndpointConfig) -> Self {
96 self.health_endpoint_config = Some(config);
97 self
98 }
99
100 pub fn request_id(mut self, enabled: bool) -> Self {
102 self.enable_request_id = enabled;
103 self
104 }
105
106 pub fn tracing(mut self, enabled: bool) -> Self {
108 self.enable_tracing = enabled;
109 self
110 }
111
112 pub fn health_endpoints(mut self, enabled: bool) -> Self {
114 self.enable_health_endpoints = enabled;
115 self
116 }
117}
118
119impl RustApi {
120 pub fn new() -> Self {
122 let _ = tracing_subscriber::registry()
124 .with(
125 EnvFilter::try_from_default_env()
126 .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
127 )
128 .with(tracing_subscriber::fmt::layer())
129 .try_init();
130
131 Self {
132 router: Router::new(),
133 openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
134 .register::<rustapi_openapi::ErrorSchema>()
135 .register::<rustapi_openapi::ErrorBodySchema>()
136 .register::<rustapi_openapi::ValidationErrorSchema>()
137 .register::<rustapi_openapi::ValidationErrorBodySchema>()
138 .register::<rustapi_openapi::FieldErrorSchema>(),
139 layers: LayerStack::new(),
140 body_limit: Some(DEFAULT_BODY_LIMIT), interceptors: InterceptorChain::new(),
142 lifecycle_hooks: LifecycleHooks::new(),
143 hot_reload: false,
144 #[cfg(feature = "http3")]
145 http3_config: None,
146 health_check: None,
147 health_endpoint_config: None,
148 status_config: None,
149 #[cfg(feature = "dashboard")]
150 dashboard_config: None,
151 }
152 }
153
154 #[cfg(feature = "swagger-ui")]
184 pub fn auto() -> Self {
185 Self::new().mount_auto_routes_grouped().docs("/docs")
186 }
187
188 #[cfg(not(feature = "swagger-ui"))]
189 pub fn auto() -> Self {
190 Self::new().mount_auto_routes_grouped()
191 }
192
193 pub fn config() -> RustApiConfig {
211 RustApiConfig::new()
212 }
213
214 pub fn body_limit(mut self, limit: usize) -> Self {
235 self.body_limit = Some(limit);
236 self
237 }
238
239 pub fn no_body_limit(mut self) -> Self {
252 self.body_limit = None;
253 self
254 }
255
256 pub fn layer<L>(mut self, layer: L) -> Self
276 where
277 L: MiddlewareLayer,
278 {
279 self.layers.push(Box::new(layer));
280 self
281 }
282
283 pub fn request_interceptor<I>(mut self, interceptor: I) -> Self
315 where
316 I: RequestInterceptor,
317 {
318 self.interceptors.add_request_interceptor(interceptor);
319 self
320 }
321
322 pub fn response_interceptor<I>(mut self, interceptor: I) -> Self
354 where
355 I: ResponseInterceptor,
356 {
357 self.interceptors.add_response_interceptor(interceptor);
358 self
359 }
360
361 pub fn state<S>(self, _state: S) -> Self
377 where
378 S: Clone + Send + Sync + 'static,
379 {
380 let state = _state;
382 let mut app = self;
383 app.router = app.router.state(state);
384 app
385 }
386
387 pub fn on_start<F, Fut>(mut self, hook: F) -> Self
404 where
405 F: FnOnce() -> Fut + Send + 'static,
406 Fut: Future<Output = ()> + Send + 'static,
407 {
408 self.lifecycle_hooks
409 .on_start
410 .push(Box::new(move || Box::pin(hook())));
411 self
412 }
413
414 pub fn on_shutdown<F, Fut>(mut self, hook: F) -> Self
431 where
432 F: FnOnce() -> Fut + Send + 'static,
433 Fut: Future<Output = ()> + Send + 'static,
434 {
435 self.lifecycle_hooks
436 .on_shutdown
437 .push(Box::new(move || Box::pin(hook())));
438 self
439 }
440
441 pub fn hot_reload(mut self, enabled: bool) -> Self {
460 self.hot_reload = enabled;
461 self
462 }
463
464 pub fn register_schema<T: rustapi_openapi::schema::RustApiSchema>(mut self) -> Self {
476 self.openapi_spec = self.openapi_spec.register::<T>();
477 self
478 }
479
480 pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
482 self.openapi_spec.info.title = title.to_string();
485 self.openapi_spec.info.version = version.to_string();
486 self.openapi_spec.info.description = description.map(|d| d.to_string());
487 self
488 }
489
490 pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
492 &self.openapi_spec
493 }
494
495 fn mount_auto_routes_grouped(mut self) -> Self {
496 let routes = crate::auto_route::collect_auto_routes();
497 let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();
499
500 for route in routes {
501 let crate::handler::Route {
502 path: route_path,
503 method,
504 handler,
505 operation,
506 component_registrar,
507 ..
508 } = route;
509
510 let method_enum = match method {
511 "GET" => http::Method::GET,
512 "POST" => http::Method::POST,
513 "PUT" => http::Method::PUT,
514 "DELETE" => http::Method::DELETE,
515 "PATCH" => http::Method::PATCH,
516 _ => http::Method::GET,
517 };
518
519 let path = if route_path.starts_with('/') {
520 route_path.to_string()
521 } else {
522 format!("/{}", route_path)
523 };
524
525 let entry = by_path.entry(path).or_default();
526 entry.insert_boxed_with_operation(method_enum, handler, operation, component_registrar);
527 }
528
529 #[cfg(feature = "tracing")]
530 let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
531 #[cfg(feature = "tracing")]
532 let path_count = by_path.len();
533
534 for (path, method_router) in by_path {
535 self = self.route(&path, method_router);
536 }
537
538 crate::trace_info!(
539 paths = path_count,
540 routes = route_count,
541 "Auto-registered routes"
542 );
543
544 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
546
547 self
548 }
549
550 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
561 for register_components in &method_router.component_registrars {
562 register_components(&mut self.openapi_spec);
563 }
564
565 for (method, op) in &method_router.operations {
567 let mut op = op.clone();
568 add_path_params_to_operation(path, &mut op, &BTreeMap::new());
569 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
570 }
571
572 self.router = self.router.route(path, method_router);
573 self
574 }
575
576 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
578 self.route(P::PATH, method_router)
579 }
580
581 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
585 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
586 self.route(path, method_router)
587 }
588
589 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
607 let method_enum = match route.method {
608 "GET" => http::Method::GET,
609 "POST" => http::Method::POST,
610 "PUT" => http::Method::PUT,
611 "DELETE" => http::Method::DELETE,
612 "PATCH" => http::Method::PATCH,
613 _ => http::Method::GET,
614 };
615
616 (route.component_registrar)(&mut self.openapi_spec);
617
618 let mut op = route.operation;
620 add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
621 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
622
623 self.route_with_method(route.path, method_enum, route.handler)
624 }
625
626 fn route_with_method(
628 self,
629 path: &str,
630 method: http::Method,
631 handler: crate::handler::BoxedHandler,
632 ) -> Self {
633 use crate::router::MethodRouter;
634 let path = if !path.starts_with('/') {
643 format!("/{}", path)
644 } else {
645 path.to_string()
646 };
647
648 let mut handlers = std::collections::HashMap::new();
657 handlers.insert(method, handler);
658
659 let method_router = MethodRouter::from_boxed(handlers);
660 self.route(&path, method_router)
661 }
662
663 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
679 let normalized_prefix = normalize_prefix_for_openapi(prefix);
681
682 for (matchit_path, method_router) in router.method_routers() {
685 for register_components in &method_router.component_registrars {
686 register_components(&mut self.openapi_spec);
687 }
688
689 let display_path = router
691 .registered_routes()
692 .get(matchit_path)
693 .map(|info| info.path.clone())
694 .unwrap_or_else(|| matchit_path.clone());
695
696 let prefixed_path = if display_path == "/" {
698 normalized_prefix.clone()
699 } else {
700 format!("{}{}", normalized_prefix, display_path)
701 };
702
703 for (method, op) in &method_router.operations {
705 let mut op = op.clone();
706 add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
707 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
708 }
709 }
710
711 self.router = self.router.nest(prefix, router);
713 self
714 }
715
716 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
745 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
746 }
747
748 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
765 use crate::router::MethodRouter;
766 use std::collections::HashMap;
767
768 let prefix = config.prefix.clone();
769 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
770
771 let handler: crate::handler::BoxedHandler =
773 std::sync::Arc::new(move |req: crate::Request| {
774 let config = config.clone();
775 let path = req.uri().path().to_string();
776
777 Box::pin(async move {
778 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
779
780 match crate::static_files::StaticFile::serve(relative_path, &config).await {
781 Ok(response) => response,
782 Err(err) => err.into_response(),
783 }
784 })
785 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
786 });
787
788 let mut handlers = HashMap::new();
789 handlers.insert(http::Method::GET, handler);
790 let method_router = MethodRouter::from_boxed(handlers);
791
792 self.route(&catch_all_path, method_router)
793 }
794
795 #[cfg(feature = "compression")]
812 pub fn compression(self) -> Self {
813 self.layer(crate::middleware::CompressionLayer::new())
814 }
815
816 #[cfg(feature = "compression")]
832 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
833 self.layer(crate::middleware::CompressionLayer::with_config(config))
834 }
835
836 #[cfg(feature = "swagger-ui")]
860 pub fn docs(self, path: &str) -> Self {
861 let title = self.openapi_spec.info.title.clone();
862 let version = self.openapi_spec.info.version.clone();
863 let description = self.openapi_spec.info.description.clone();
864
865 self.docs_with_info(path, &title, &version, description.as_deref())
866 }
867
868 #[cfg(feature = "swagger-ui")]
877 pub fn docs_with_info(
878 mut self,
879 path: &str,
880 title: &str,
881 version: &str,
882 description: Option<&str>,
883 ) -> Self {
884 use crate::router::get;
885 self.openapi_spec.info.title = title.to_string();
887 self.openapi_spec.info.version = version.to_string();
888 if let Some(desc) = description {
889 self.openapi_spec.info.description = Some(desc.to_string());
890 }
891
892 let path = path.trim_end_matches('/');
893 let openapi_path = format!("{}/openapi.json", path);
894
895 let spec_value = self.openapi_spec.to_json();
897 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
898 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
900 "{}".to_string()
901 });
902 let openapi_url = openapi_path.clone();
903
904 let spec_handler = move || {
906 let json = spec_json.clone();
907 async move {
908 http::Response::builder()
909 .status(http::StatusCode::OK)
910 .header(http::header::CONTENT_TYPE, "application/json")
911 .body(crate::response::Body::from(json))
912 .unwrap_or_else(|e| {
913 tracing::error!("Failed to build response: {}", e);
914 http::Response::builder()
915 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
916 .body(crate::response::Body::from("Internal Server Error"))
917 .unwrap()
918 })
919 }
920 };
921
922 let docs_handler = move || {
924 let url = openapi_url.clone();
925 async move {
926 let response = rustapi_openapi::swagger_ui_html(&url);
927 response.map(crate::response::Body::Full)
928 }
929 };
930
931 self.route(&openapi_path, get(spec_handler))
932 .route(path, get(docs_handler))
933 }
934
935 #[cfg(feature = "swagger-ui")]
951 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
952 let title = self.openapi_spec.info.title.clone();
953 let version = self.openapi_spec.info.version.clone();
954 let description = self.openapi_spec.info.description.clone();
955
956 self.docs_with_auth_and_info(
957 path,
958 username,
959 password,
960 &title,
961 &version,
962 description.as_deref(),
963 )
964 }
965
966 #[cfg(feature = "swagger-ui")]
982 pub fn docs_with_auth_and_info(
983 mut self,
984 path: &str,
985 username: &str,
986 password: &str,
987 title: &str,
988 version: &str,
989 description: Option<&str>,
990 ) -> Self {
991 use crate::router::MethodRouter;
992 use base64::{engine::general_purpose::STANDARD, Engine};
993 use std::collections::HashMap;
994
995 self.openapi_spec.info.title = title.to_string();
997 self.openapi_spec.info.version = version.to_string();
998 if let Some(desc) = description {
999 self.openapi_spec.info.description = Some(desc.to_string());
1000 }
1001
1002 let path = path.trim_end_matches('/');
1003 let openapi_path = format!("{}/openapi.json", path);
1004
1005 let credentials = format!("{}:{}", username, password);
1007 let encoded = STANDARD.encode(credentials.as_bytes());
1008 let expected_auth = format!("Basic {}", encoded);
1009
1010 let spec_value = self.openapi_spec.to_json();
1012 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
1013 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
1014 "{}".to_string()
1015 });
1016 let openapi_url = openapi_path.clone();
1017 let expected_auth_spec = expected_auth.clone();
1018 let expected_auth_docs = expected_auth;
1019
1020 let spec_handler: crate::handler::BoxedHandler =
1022 std::sync::Arc::new(move |req: crate::Request| {
1023 let json = spec_json.clone();
1024 let expected = expected_auth_spec.clone();
1025 Box::pin(async move {
1026 if !check_basic_auth(&req, &expected) {
1027 return unauthorized_response();
1028 }
1029 http::Response::builder()
1030 .status(http::StatusCode::OK)
1031 .header(http::header::CONTENT_TYPE, "application/json")
1032 .body(crate::response::Body::from(json))
1033 .unwrap_or_else(|e| {
1034 tracing::error!("Failed to build response: {}", e);
1035 http::Response::builder()
1036 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1037 .body(crate::response::Body::from("Internal Server Error"))
1038 .unwrap()
1039 })
1040 })
1041 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1042 });
1043
1044 let docs_handler: crate::handler::BoxedHandler =
1046 std::sync::Arc::new(move |req: crate::Request| {
1047 let url = openapi_url.clone();
1048 let expected = expected_auth_docs.clone();
1049 Box::pin(async move {
1050 if !check_basic_auth(&req, &expected) {
1051 return unauthorized_response();
1052 }
1053 let response = rustapi_openapi::swagger_ui_html(&url);
1054 response.map(crate::response::Body::Full)
1055 })
1056 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1057 });
1058
1059 let mut spec_handlers = HashMap::new();
1061 spec_handlers.insert(http::Method::GET, spec_handler);
1062 let spec_router = MethodRouter::from_boxed(spec_handlers);
1063
1064 let mut docs_handlers = HashMap::new();
1065 docs_handlers.insert(http::Method::GET, docs_handler);
1066 let docs_router = MethodRouter::from_boxed(docs_handlers);
1067
1068 self.route(&openapi_path, spec_router)
1069 .route(path, docs_router)
1070 }
1071
1072 pub fn status_page(self) -> Self {
1074 self.status_page_with_config(crate::status::StatusConfig::default())
1075 }
1076
1077 pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self {
1079 self.status_config = Some(config);
1080 self
1081 }
1082
1083 pub fn health_endpoints(mut self) -> Self {
1088 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1089 if self.health_check.is_none() {
1090 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1091 }
1092 self
1093 }
1094
1095 pub fn health_endpoints_with_config(
1097 mut self,
1098 config: crate::health::HealthEndpointConfig,
1099 ) -> Self {
1100 self.health_endpoint_config = Some(config);
1101 if self.health_check.is_none() {
1102 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1103 }
1104 self
1105 }
1106
1107 pub fn with_health_check(mut self, health_check: crate::health::HealthCheck) -> Self {
1112 self.health_check = Some(health_check);
1113 if self.health_endpoint_config.is_none() {
1114 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1115 }
1116 self
1117 }
1118
1119 pub fn production_defaults(self, service_name: impl Into<String>) -> Self {
1126 self.production_defaults_with_config(ProductionDefaultsConfig::new(service_name))
1127 }
1128
1129 pub fn production_defaults_with_config(mut self, config: ProductionDefaultsConfig) -> Self {
1131 if config.enable_request_id {
1132 self = self.layer(crate::middleware::RequestIdLayer::new());
1133 }
1134
1135 if config.enable_tracing {
1136 let mut tracing_layer =
1137 crate::middleware::TracingLayer::with_level(config.tracing_level)
1138 .with_field("service", config.service_name.clone())
1139 .with_field("environment", crate::error::get_environment().to_string());
1140
1141 if let Some(version) = &config.version {
1142 tracing_layer = tracing_layer.with_field("version", version.clone());
1143 }
1144
1145 self = self.layer(tracing_layer);
1146 }
1147
1148 if config.enable_health_endpoints {
1149 if self.health_check.is_none() {
1150 let mut builder = crate::health::HealthCheckBuilder::default();
1151 if let Some(version) = &config.version {
1152 builder = builder.version(version.clone());
1153 }
1154 self.health_check = Some(builder.build());
1155 }
1156
1157 if self.health_endpoint_config.is_none() {
1158 self.health_endpoint_config =
1159 Some(config.health_endpoint_config.unwrap_or_default());
1160 }
1161 }
1162
1163 self
1164 }
1165
1166 fn print_hot_reload_banner(&self, addr: &str) {
1168 if !self.hot_reload {
1169 return;
1170 }
1171
1172 std::env::set_var("RUSTAPI_HOT_RELOAD", "1");
1174
1175 let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD")
1176 .map(|v| v == "1")
1177 .unwrap_or(false);
1178
1179 tracing::info!("🔄 Hot-reload mode enabled");
1180
1181 if is_under_watcher {
1182 tracing::info!(" File watcher active — changes will trigger rebuild + restart");
1183 } else {
1184 tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload");
1185 }
1186
1187 tracing::info!(" Listening on http://{addr}");
1188 }
1189
1190 fn apply_health_endpoints(&mut self) {
1192 if let Some(config) = &self.health_endpoint_config {
1193 use crate::router::get;
1194
1195 let health_check = self
1196 .health_check
1197 .clone()
1198 .unwrap_or_else(|| crate::health::HealthCheckBuilder::default().build());
1199
1200 let health_path = config.health_path.clone();
1201 let readiness_path = config.readiness_path.clone();
1202 let liveness_path = config.liveness_path.clone();
1203
1204 let health_handler = {
1205 let health_check = health_check.clone();
1206 move || {
1207 let health_check = health_check.clone();
1208 async move { crate::health::health_response(health_check).await }
1209 }
1210 };
1211
1212 let readiness_handler = {
1213 let health_check = health_check.clone();
1214 move || {
1215 let health_check = health_check.clone();
1216 async move { crate::health::readiness_response(health_check).await }
1217 }
1218 };
1219
1220 let liveness_handler = || async { crate::health::liveness_response().await };
1221
1222 let router = std::mem::take(&mut self.router);
1223 self.router = router
1224 .route(&health_path, get(health_handler))
1225 .route(&readiness_path, get(readiness_handler))
1226 .route(&liveness_path, get(liveness_handler));
1227 }
1228 }
1229
1230 fn apply_status_page(&mut self) {
1231 if let Some(config) = &self.status_config {
1232 let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new());
1233
1234 self.layers
1236 .push(Box::new(crate::status::StatusLayer::new(monitor.clone())));
1237
1238 use crate::router::MethodRouter;
1240 use std::collections::HashMap;
1241
1242 let monitor = monitor.clone();
1243 let config = config.clone();
1244 let path = config.path.clone(); let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| {
1247 let monitor = monitor.clone();
1248 let config = config.clone();
1249 Box::pin(async move {
1250 crate::status::status_handler(monitor, config)
1251 .await
1252 .into_response()
1253 })
1254 });
1255
1256 let mut handlers = HashMap::new();
1257 handlers.insert(http::Method::GET, handler);
1258 let method_router = MethodRouter::from_boxed(handlers);
1259
1260 let router = std::mem::take(&mut self.router);
1262 self.router = router.route(&path, method_router);
1263 }
1264 }
1265
1266 #[cfg(feature = "dashboard")]
1267 fn apply_dashboard(&mut self) {
1268 use crate::dashboard::{DashboardMetrics, RouteInventoryItem};
1269 use crate::handler::BoxedHandler;
1270 use crate::response::Body;
1271 use crate::router::MethodRouter;
1272 use std::collections::HashMap;
1273 use std::sync::Arc;
1274
1275 let mut config = match self.dashboard_config.take() {
1276 Some(c) => c,
1277 None => return,
1278 };
1279 config.normalize_paths();
1280
1281 let mut inventory: Vec<RouteInventoryItem> = self
1285 .router
1286 .registered_routes()
1287 .values()
1288 .map(|info| {
1289 let methods: Vec<String> = info.methods.iter().map(|m| m.to_string()).collect();
1290 let health_eligible = self
1291 .health_endpoint_config
1292 .as_ref()
1293 .map(|health| {
1294 info.path == health.health_path
1295 || info.path == health.readiness_path
1296 || info.path == health.liveness_path
1297 })
1298 .unwrap_or(false);
1299
1300 RouteInventoryItem::new(info.path.clone(), methods)
1301 .with_tags(openapi_tags_for_route(
1302 &self.openapi_spec,
1303 &info.path,
1304 &info.methods,
1305 ))
1306 .with_feature_gates(infer_route_feature_gates(&info.path))
1307 .health_eligible(health_eligible)
1308 .replay_eligible(is_dashboard_replay_eligible(&info.path, health_eligible))
1309 })
1310 .collect();
1311 inventory.sort_by(|a, b| a.path.cmp(&b.path));
1312
1313 let metrics = Arc::new(DashboardMetrics::new_with_replay_admin_path(
1314 inventory,
1315 config.replay_api_path.clone(),
1316 ));
1317
1318 let router = std::mem::take(&mut self.router);
1320 self.router = router.state(Arc::clone(&metrics));
1321
1322 let prefix = config.path.trim_end_matches('/').to_owned();
1324
1325 fn not_found() -> crate::response::Response {
1326 http::Response::builder()
1327 .status(404)
1328 .body(Body::Full(http_body_util::Full::new(bytes::Bytes::from(
1329 "Not Found",
1330 ))))
1331 .unwrap()
1332 }
1333
1334 {
1336 let metrics_c = Arc::clone(&metrics);
1337 let config_c = config.clone();
1338 let handler: BoxedHandler = Arc::new(move |req| {
1339 let metrics = Arc::clone(&metrics_c);
1340 let cfg = config_c.clone();
1341 Box::pin(async move {
1342 let headers = req.headers().clone();
1343 let method = req.method().to_string();
1344 let path = req.uri().path().to_owned();
1345 crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg)
1346 .await
1347 .unwrap_or_else(not_found)
1348 })
1349 });
1350 let mut h = HashMap::new();
1351 h.insert(http::Method::GET, handler);
1352 let router = std::mem::take(&mut self.router);
1353 self.router = router.route(&prefix, MethodRouter::from_boxed(h));
1354 }
1355
1356 {
1358 let metrics_c = Arc::clone(&metrics);
1359 let config_c = config.clone();
1360 let wildcard_path = format!("{}/*path", prefix);
1361 let handler: BoxedHandler = Arc::new(move |req| {
1362 let metrics = Arc::clone(&metrics_c);
1363 let cfg = config_c.clone();
1364 Box::pin(async move {
1365 let headers = req.headers().clone();
1366 let method = req.method().to_string();
1367 let path = req.uri().path().to_owned();
1368 crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg)
1369 .await
1370 .unwrap_or_else(not_found)
1371 })
1372 });
1373 let mut h = HashMap::new();
1374 h.insert(http::Method::GET, handler);
1375 let router = std::mem::take(&mut self.router);
1376 self.router = router.route(&wildcard_path, MethodRouter::from_boxed(h));
1377 }
1378 }
1379
1380 #[cfg(feature = "dashboard")]
1400 pub fn dashboard(mut self, config: crate::dashboard::DashboardConfig) -> Self {
1401 self.dashboard_config = Some(config);
1402 self
1403 }
1404
1405 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1416 self.print_hot_reload_banner(addr);
1418
1419 self.apply_health_endpoints();
1421
1422 self.apply_status_page();
1424
1425 #[cfg(feature = "dashboard")]
1427 self.apply_dashboard();
1428
1429 if let Some(limit) = self.body_limit {
1431 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1433 }
1434
1435 for hook in self.lifecycle_hooks.on_start {
1437 hook().await;
1438 }
1439
1440 let server = Server::new(self.router, self.layers, self.interceptors);
1441 server.run(addr).await
1442 }
1443
1444 pub async fn run_with_shutdown<F>(
1446 mut self,
1447 addr: impl AsRef<str>,
1448 signal: F,
1449 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1450 where
1451 F: std::future::Future<Output = ()> + Send + 'static,
1452 {
1453 self.print_hot_reload_banner(addr.as_ref());
1455
1456 self.apply_health_endpoints();
1458
1459 self.apply_status_page();
1461
1462 #[cfg(feature = "dashboard")]
1464 self.apply_dashboard();
1465
1466 if let Some(limit) = self.body_limit {
1467 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1468 }
1469
1470 for hook in self.lifecycle_hooks.on_start {
1472 hook().await;
1473 }
1474
1475 let shutdown_hooks = self.lifecycle_hooks.on_shutdown;
1477 let wrapped_signal = async move {
1478 signal.await;
1479 for hook in shutdown_hooks {
1481 hook().await;
1482 }
1483 };
1484
1485 let server = Server::new(self.router, self.layers, self.interceptors);
1486 server
1487 .run_with_shutdown(addr.as_ref(), wrapped_signal)
1488 .await
1489 }
1490
1491 pub fn into_router(self) -> Router {
1493 self.router
1494 }
1495
1496 pub fn layers(&self) -> &LayerStack {
1498 &self.layers
1499 }
1500
1501 pub fn interceptors(&self) -> &InterceptorChain {
1503 &self.interceptors
1504 }
1505
1506 #[cfg(feature = "http3")]
1520 pub async fn run_http3(
1521 mut self,
1522 config: crate::http3::Http3Config,
1523 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1524 use std::sync::Arc;
1525
1526 self.apply_health_endpoints();
1528
1529 self.apply_status_page();
1531
1532 if let Some(limit) = self.body_limit {
1534 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1535 }
1536
1537 let server = crate::http3::Http3Server::new(
1538 &config,
1539 Arc::new(self.router),
1540 Arc::new(self.layers),
1541 Arc::new(self.interceptors),
1542 )
1543 .await?;
1544
1545 server.run().await
1546 }
1547
1548 #[cfg(feature = "http3-dev")]
1562 pub async fn run_http3_dev(
1563 mut self,
1564 addr: &str,
1565 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1566 use std::sync::Arc;
1567
1568 self.apply_health_endpoints();
1570
1571 self.apply_status_page();
1573
1574 if let Some(limit) = self.body_limit {
1576 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1577 }
1578
1579 let server = crate::http3::Http3Server::new_with_self_signed(
1580 addr,
1581 Arc::new(self.router),
1582 Arc::new(self.layers),
1583 Arc::new(self.interceptors),
1584 )
1585 .await?;
1586
1587 server.run().await
1588 }
1589
1590 #[cfg(feature = "http3")]
1601 pub fn with_http3(mut self, cert_path: impl Into<String>, key_path: impl Into<String>) -> Self {
1602 self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path));
1603 self
1604 }
1605
1606 #[cfg(feature = "http3")]
1621 pub async fn run_dual_stack(
1622 mut self,
1623 http_addr: &str,
1624 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1625 use std::sync::Arc;
1626
1627 let mut config = self
1628 .http3_config
1629 .take()
1630 .ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
1631
1632 let http_socket: std::net::SocketAddr = http_addr.parse()?;
1633 config.bind_addr = if http_socket.ip().is_ipv6() {
1634 format!("[{}]", http_socket.ip())
1635 } else {
1636 http_socket.ip().to_string()
1637 };
1638 config.port = http_socket.port();
1639 let http_addr = http_socket.to_string();
1640
1641 self.apply_health_endpoints();
1643
1644 self.apply_status_page();
1646
1647 if let Some(limit) = self.body_limit {
1649 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1650 }
1651
1652 let router = Arc::new(self.router);
1653 let layers = Arc::new(self.layers);
1654 let interceptors = Arc::new(self.interceptors);
1655
1656 let http1_server =
1657 Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1658 let http3_server =
1659 crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1660
1661 tracing::info!(
1662 http1_addr = %http_addr,
1663 http3_addr = %config.socket_addr(),
1664 "Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1665 );
1666
1667 tokio::try_join!(
1668 http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1669 http3_server.run_with_shutdown(std::future::pending::<()>()),
1670 )?;
1671
1672 Ok(())
1673 }
1674}
1675
1676#[cfg(feature = "dashboard")]
1677fn openapi_tags_for_route(
1678 spec: &rustapi_openapi::OpenApiSpec,
1679 path: &str,
1680 methods: &[http::Method],
1681) -> Vec<String> {
1682 let Some(path_item) = spec.paths.get(path) else {
1683 return Vec::new();
1684 };
1685
1686 let mut tags = BTreeSet::new();
1687 for method in methods {
1688 if let Some(operation) = operation_for_method(path_item, method) {
1689 tags.extend(operation.tags.iter().cloned());
1690 }
1691 }
1692
1693 tags.into_iter().collect()
1694}
1695
1696#[cfg(feature = "dashboard")]
1697fn operation_for_method<'a>(
1698 path_item: &'a rustapi_openapi::PathItem,
1699 method: &http::Method,
1700) -> Option<&'a rustapi_openapi::Operation> {
1701 match *method {
1702 http::Method::GET => path_item.get.as_ref(),
1703 http::Method::POST => path_item.post.as_ref(),
1704 http::Method::PUT => path_item.put.as_ref(),
1705 http::Method::PATCH => path_item.patch.as_ref(),
1706 http::Method::DELETE => path_item.delete.as_ref(),
1707 http::Method::HEAD => path_item.head.as_ref(),
1708 http::Method::OPTIONS => path_item.options.as_ref(),
1709 http::Method::TRACE => path_item.trace.as_ref(),
1710 _ => None,
1711 }
1712}
1713
1714#[cfg(feature = "dashboard")]
1715fn infer_route_feature_gates(path: &str) -> Vec<String> {
1716 if path.contains("openapi") || path.contains("docs") {
1717 vec!["core-openapi".to_string()]
1718 } else if path.starts_with("/__rustapi/replays") {
1719 vec!["extras-replay".to_string()]
1720 } else {
1721 Vec::new()
1722 }
1723}
1724
1725#[cfg(feature = "dashboard")]
1726fn is_dashboard_replay_eligible(path: &str, health_eligible: bool) -> bool {
1727 !health_eligible && !path.starts_with("/__rustapi/")
1728}
1729
1730fn add_path_params_to_operation(
1731 path: &str,
1732 op: &mut rustapi_openapi::Operation,
1733 param_schemas: &BTreeMap<String, String>,
1734) {
1735 let mut params: Vec<String> = Vec::new();
1736 let mut in_brace = false;
1737 let mut current = String::new();
1738
1739 for ch in path.chars() {
1740 match ch {
1741 '{' => {
1742 in_brace = true;
1743 current.clear();
1744 }
1745 '}' => {
1746 if in_brace {
1747 in_brace = false;
1748 if !current.is_empty() {
1749 params.push(current.clone());
1750 }
1751 }
1752 }
1753 _ => {
1754 if in_brace {
1755 current.push(ch);
1756 }
1757 }
1758 }
1759 }
1760
1761 if params.is_empty() {
1762 return;
1763 }
1764
1765 let op_params = &mut op.parameters;
1766
1767 for name in params {
1768 let already = op_params
1769 .iter()
1770 .any(|p| p.location == "path" && p.name == name);
1771 if already {
1772 continue;
1773 }
1774
1775 let schema = if let Some(schema_type) = param_schemas.get(&name) {
1777 schema_type_to_openapi_schema(schema_type)
1778 } else {
1779 infer_path_param_schema(&name)
1780 };
1781
1782 op_params.push(rustapi_openapi::Parameter {
1783 name,
1784 location: "path".to_string(),
1785 required: true,
1786 description: None,
1787 deprecated: None,
1788 schema: Some(schema),
1789 });
1790 }
1791}
1792
1793fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef {
1795 match schema_type.to_lowercase().as_str() {
1796 "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1797 "type": "string",
1798 "format": "uuid"
1799 })),
1800 "integer" | "int" | "int64" | "i64" => {
1801 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1802 "type": "integer",
1803 "format": "int64"
1804 }))
1805 }
1806 "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1807 "type": "integer",
1808 "format": "int32"
1809 })),
1810 "number" | "float" | "f64" | "f32" => {
1811 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1812 "type": "number"
1813 }))
1814 }
1815 "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1816 "type": "boolean"
1817 })),
1818 _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1819 "type": "string"
1820 })),
1821 }
1822}
1823
1824fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
1833 let lower = name.to_lowercase();
1834
1835 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
1837
1838 if is_uuid {
1839 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1840 "type": "string",
1841 "format": "uuid"
1842 }));
1843 }
1844
1845 let is_integer = lower == "page"
1848 || lower == "limit"
1849 || lower == "offset"
1850 || lower == "count"
1851 || lower.ends_with("_count")
1852 || lower.ends_with("_num")
1853 || lower == "year"
1854 || lower == "month"
1855 || lower == "day"
1856 || lower == "index"
1857 || lower == "position";
1858
1859 if is_integer {
1860 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1861 "type": "integer",
1862 "format": "int64"
1863 }))
1864 } else {
1865 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
1866 }
1867}
1868
1869fn normalize_prefix_for_openapi(prefix: &str) -> String {
1876 if prefix.is_empty() {
1878 return "/".to_string();
1879 }
1880
1881 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
1883
1884 if segments.is_empty() {
1886 return "/".to_string();
1887 }
1888
1889 let mut result = String::with_capacity(prefix.len() + 1);
1891 for segment in segments {
1892 result.push('/');
1893 result.push_str(segment);
1894 }
1895
1896 result
1897}
1898
1899impl Default for RustApi {
1900 fn default() -> Self {
1901 Self::new()
1902 }
1903}
1904
1905#[cfg(feature = "swagger-ui")]
1907fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1908 req.headers()
1909 .get(http::header::AUTHORIZATION)
1910 .and_then(|v| v.to_str().ok())
1911 .map(|auth| auth == expected)
1912 .unwrap_or(false)
1913}
1914
1915#[cfg(feature = "swagger-ui")]
1917fn unauthorized_response() -> crate::Response {
1918 http::Response::builder()
1919 .status(http::StatusCode::UNAUTHORIZED)
1920 .header(
1921 http::header::WWW_AUTHENTICATE,
1922 "Basic realm=\"API Documentation\"",
1923 )
1924 .header(http::header::CONTENT_TYPE, "text/plain")
1925 .body(crate::response::Body::from("Unauthorized"))
1926 .unwrap()
1927}
1928
1929pub struct RustApiConfig {
1931 docs_path: Option<String>,
1932 docs_enabled: bool,
1933 api_title: String,
1934 api_version: String,
1935 api_description: Option<String>,
1936 body_limit: Option<usize>,
1937 layers: LayerStack,
1938}
1939
1940impl Default for RustApiConfig {
1941 fn default() -> Self {
1942 Self::new()
1943 }
1944}
1945
1946impl RustApiConfig {
1947 pub fn new() -> Self {
1948 Self {
1949 docs_path: Some("/docs".to_string()),
1950 docs_enabled: true,
1951 api_title: "RustAPI".to_string(),
1952 api_version: "1.0.0".to_string(),
1953 api_description: None,
1954 body_limit: None,
1955 layers: LayerStack::new(),
1956 }
1957 }
1958
1959 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
1961 self.docs_path = Some(path.into());
1962 self
1963 }
1964
1965 pub fn docs_enabled(mut self, enabled: bool) -> Self {
1967 self.docs_enabled = enabled;
1968 self
1969 }
1970
1971 pub fn openapi_info(
1973 mut self,
1974 title: impl Into<String>,
1975 version: impl Into<String>,
1976 description: Option<impl Into<String>>,
1977 ) -> Self {
1978 self.api_title = title.into();
1979 self.api_version = version.into();
1980 self.api_description = description.map(|d| d.into());
1981 self
1982 }
1983
1984 pub fn body_limit(mut self, limit: usize) -> Self {
1986 self.body_limit = Some(limit);
1987 self
1988 }
1989
1990 pub fn layer<L>(mut self, layer: L) -> Self
1992 where
1993 L: MiddlewareLayer,
1994 {
1995 self.layers.push(Box::new(layer));
1996 self
1997 }
1998
1999 pub fn build(self) -> RustApi {
2001 let mut app = RustApi::new().mount_auto_routes_grouped();
2002
2003 if let Some(limit) = self.body_limit {
2005 app = app.body_limit(limit);
2006 }
2007
2008 app = app.openapi_info(
2009 &self.api_title,
2010 &self.api_version,
2011 self.api_description.as_deref(),
2012 );
2013
2014 #[cfg(feature = "swagger-ui")]
2015 if self.docs_enabled {
2016 if let Some(path) = self.docs_path {
2017 app = app.docs(&path);
2018 }
2019 }
2020
2021 app.layers.extend(self.layers);
2024
2025 app
2026 }
2027
2028 pub async fn run(
2030 self,
2031 addr: impl AsRef<str>,
2032 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2033 self.build().run(addr.as_ref()).await
2034 }
2035}
2036
2037#[cfg(test)]
2038mod tests {
2039 use super::RustApi;
2040 use crate::extract::{FromRequestParts, State};
2041 use crate::path_params::PathParams;
2042 use crate::request::Request;
2043 use crate::router::{get, post, Router};
2044 use bytes::Bytes;
2045 use http::Method;
2046 use proptest::prelude::*;
2047
2048 #[test]
2049 fn state_is_available_via_extractor() {
2050 let app = RustApi::new().state(123u32);
2051 let router = app.into_router();
2052
2053 let req = http::Request::builder()
2054 .method(Method::GET)
2055 .uri("/test")
2056 .body(())
2057 .unwrap();
2058 let (parts, _) = req.into_parts();
2059
2060 let request = Request::new(
2061 parts,
2062 crate::request::BodyVariant::Buffered(Bytes::new()),
2063 router.state_ref(),
2064 PathParams::new(),
2065 );
2066 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
2067 assert_eq!(value, 123u32);
2068 }
2069
2070 #[test]
2071 fn test_path_param_type_inference_integer() {
2072 use super::infer_path_param_schema;
2073
2074 let int_params = [
2076 "page",
2077 "limit",
2078 "offset",
2079 "count",
2080 "item_count",
2081 "year",
2082 "month",
2083 "day",
2084 "index",
2085 "position",
2086 ];
2087
2088 for name in int_params {
2089 let schema = infer_path_param_schema(name);
2090 match schema {
2091 rustapi_openapi::SchemaRef::Inline(v) => {
2092 assert_eq!(
2093 v.get("type").and_then(|v| v.as_str()),
2094 Some("integer"),
2095 "Expected '{}' to be inferred as integer",
2096 name
2097 );
2098 }
2099 _ => panic!("Expected inline schema for '{}'", name),
2100 }
2101 }
2102 }
2103
2104 #[test]
2105 fn test_path_param_type_inference_uuid() {
2106 use super::infer_path_param_schema;
2107
2108 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
2110
2111 for name in uuid_params {
2112 let schema = infer_path_param_schema(name);
2113 match schema {
2114 rustapi_openapi::SchemaRef::Inline(v) => {
2115 assert_eq!(
2116 v.get("type").and_then(|v| v.as_str()),
2117 Some("string"),
2118 "Expected '{}' to be inferred as string",
2119 name
2120 );
2121 assert_eq!(
2122 v.get("format").and_then(|v| v.as_str()),
2123 Some("uuid"),
2124 "Expected '{}' to have uuid format",
2125 name
2126 );
2127 }
2128 _ => panic!("Expected inline schema for '{}'", name),
2129 }
2130 }
2131 }
2132
2133 #[test]
2134 fn test_path_param_type_inference_string() {
2135 use super::infer_path_param_schema;
2136
2137 let string_params = [
2139 "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId",
2140 ];
2141
2142 for name in string_params {
2143 let schema = infer_path_param_schema(name);
2144 match schema {
2145 rustapi_openapi::SchemaRef::Inline(v) => {
2146 assert_eq!(
2147 v.get("type").and_then(|v| v.as_str()),
2148 Some("string"),
2149 "Expected '{}' to be inferred as string",
2150 name
2151 );
2152 assert!(
2153 v.get("format").is_none()
2154 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
2155 "Expected '{}' to NOT have uuid format",
2156 name
2157 );
2158 }
2159 _ => panic!("Expected inline schema for '{}'", name),
2160 }
2161 }
2162 }
2163
2164 #[test]
2165 fn test_schema_type_to_openapi_schema() {
2166 use super::schema_type_to_openapi_schema;
2167
2168 let uuid_schema = schema_type_to_openapi_schema("uuid");
2170 match uuid_schema {
2171 rustapi_openapi::SchemaRef::Inline(v) => {
2172 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2173 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid"));
2174 }
2175 _ => panic!("Expected inline schema for uuid"),
2176 }
2177
2178 for schema_type in ["integer", "int", "int64", "i64"] {
2180 let schema = schema_type_to_openapi_schema(schema_type);
2181 match schema {
2182 rustapi_openapi::SchemaRef::Inline(v) => {
2183 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
2184 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64"));
2185 }
2186 _ => panic!("Expected inline schema for {}", schema_type),
2187 }
2188 }
2189
2190 let int32_schema = schema_type_to_openapi_schema("int32");
2192 match int32_schema {
2193 rustapi_openapi::SchemaRef::Inline(v) => {
2194 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
2195 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32"));
2196 }
2197 _ => panic!("Expected inline schema for int32"),
2198 }
2199
2200 for schema_type in ["number", "float"] {
2202 let schema = schema_type_to_openapi_schema(schema_type);
2203 match schema {
2204 rustapi_openapi::SchemaRef::Inline(v) => {
2205 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number"));
2206 }
2207 _ => panic!("Expected inline schema for {}", schema_type),
2208 }
2209 }
2210
2211 for schema_type in ["boolean", "bool"] {
2213 let schema = schema_type_to_openapi_schema(schema_type);
2214 match schema {
2215 rustapi_openapi::SchemaRef::Inline(v) => {
2216 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean"));
2217 }
2218 _ => panic!("Expected inline schema for {}", schema_type),
2219 }
2220 }
2221
2222 let string_schema = schema_type_to_openapi_schema("string");
2224 match string_schema {
2225 rustapi_openapi::SchemaRef::Inline(v) => {
2226 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2227 }
2228 _ => panic!("Expected inline schema for string"),
2229 }
2230 }
2231
2232 proptest! {
2239 #![proptest_config(ProptestConfig::with_cases(100))]
2240
2241 #[test]
2246 fn prop_nested_routes_in_openapi_spec(
2247 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2249 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2251 has_param in any::<bool>(),
2252 ) {
2253 async fn handler() -> &'static str { "handler" }
2254
2255 let prefix = format!("/{}", prefix_segments.join("/"));
2257
2258 let mut route_path = format!("/{}", route_segments.join("/"));
2260 if has_param {
2261 route_path.push_str("/{id}");
2262 }
2263
2264 let nested_router = Router::new().route(&route_path, get(handler));
2266 let app = RustApi::new().nest(&prefix, nested_router);
2267
2268 let expected_openapi_path = format!("{}{}", prefix, route_path);
2270
2271 let spec = app.openapi_spec();
2273
2274 prop_assert!(
2276 spec.paths.contains_key(&expected_openapi_path),
2277 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2278 expected_openapi_path,
2279 spec.paths.keys().collect::<Vec<_>>()
2280 );
2281
2282 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2284 prop_assert!(
2285 path_item.get.is_some(),
2286 "GET operation should exist for path '{}'",
2287 expected_openapi_path
2288 );
2289 }
2290
2291 #[test]
2296 fn prop_multiple_methods_preserved_in_openapi(
2297 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2298 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2299 ) {
2300 async fn get_handler() -> &'static str { "get" }
2301 async fn post_handler() -> &'static str { "post" }
2302
2303 let prefix = format!("/{}", prefix_segments.join("/"));
2305 let route_path = format!("/{}", route_segments.join("/"));
2306
2307 let get_route_path = format!("{}/get", route_path);
2310 let post_route_path = format!("{}/post", route_path);
2311 let nested_router = Router::new()
2312 .route(&get_route_path, get(get_handler))
2313 .route(&post_route_path, post(post_handler));
2314 let app = RustApi::new().nest(&prefix, nested_router);
2315
2316 let expected_get_path = format!("{}{}", prefix, get_route_path);
2318 let expected_post_path = format!("{}{}", prefix, post_route_path);
2319
2320 let spec = app.openapi_spec();
2322
2323 prop_assert!(
2325 spec.paths.contains_key(&expected_get_path),
2326 "Expected OpenAPI path '{}' not found",
2327 expected_get_path
2328 );
2329 prop_assert!(
2330 spec.paths.contains_key(&expected_post_path),
2331 "Expected OpenAPI path '{}' not found",
2332 expected_post_path
2333 );
2334
2335 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
2337 prop_assert!(
2338 get_path_item.get.is_some(),
2339 "GET operation should exist for path '{}'",
2340 expected_get_path
2341 );
2342
2343 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
2345 prop_assert!(
2346 post_path_item.post.is_some(),
2347 "POST operation should exist for path '{}'",
2348 expected_post_path
2349 );
2350 }
2351
2352 #[test]
2357 fn prop_path_params_in_openapi_after_nesting(
2358 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2359 param_name in "[a-z][a-z0-9]{0,5}",
2360 ) {
2361 async fn handler() -> &'static str { "handler" }
2362
2363 let prefix = format!("/{}", prefix_segments.join("/"));
2365 let route_path = format!("/{{{}}}", param_name);
2366
2367 let nested_router = Router::new().route(&route_path, get(handler));
2369 let app = RustApi::new().nest(&prefix, nested_router);
2370
2371 let expected_openapi_path = format!("{}{}", prefix, route_path);
2373
2374 let spec = app.openapi_spec();
2376
2377 prop_assert!(
2379 spec.paths.contains_key(&expected_openapi_path),
2380 "Expected OpenAPI path '{}' not found",
2381 expected_openapi_path
2382 );
2383
2384 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2386 let get_op = path_item.get.as_ref().unwrap();
2387
2388 prop_assert!(
2389 !get_op.parameters.is_empty(),
2390 "Operation should have parameters for path '{}'",
2391 expected_openapi_path
2392 );
2393
2394 let params = &get_op.parameters;
2395 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
2396 prop_assert!(
2397 has_param,
2398 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
2399 param_name,
2400 params.iter().map(|p| &p.name).collect::<Vec<_>>()
2401 );
2402 }
2403 }
2404
2405 proptest! {
2413 #![proptest_config(ProptestConfig::with_cases(100))]
2414
2415 #[test]
2420 fn prop_rustapi_nest_delegates_to_router_nest(
2421 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2422 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2423 has_param in any::<bool>(),
2424 ) {
2425 async fn handler() -> &'static str { "handler" }
2426
2427 let prefix = format!("/{}", prefix_segments.join("/"));
2429
2430 let mut route_path = format!("/{}", route_segments.join("/"));
2432 if has_param {
2433 route_path.push_str("/{id}");
2434 }
2435
2436 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2438 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2439
2440 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2442 let rustapi_router = rustapi_app.into_router();
2443
2444 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2446
2447 let rustapi_routes = rustapi_router.registered_routes();
2449 let router_routes = router_app.registered_routes();
2450
2451 prop_assert_eq!(
2452 rustapi_routes.len(),
2453 router_routes.len(),
2454 "RustApi and Router should have same number of routes"
2455 );
2456
2457 for (path, info) in router_routes {
2459 prop_assert!(
2460 rustapi_routes.contains_key(path),
2461 "Route '{}' from Router should exist in RustApi routes",
2462 path
2463 );
2464
2465 let rustapi_info = rustapi_routes.get(path).unwrap();
2466 prop_assert_eq!(
2467 &info.path, &rustapi_info.path,
2468 "Display paths should match for route '{}'",
2469 path
2470 );
2471 prop_assert_eq!(
2472 info.methods.len(), rustapi_info.methods.len(),
2473 "Method count should match for route '{}'",
2474 path
2475 );
2476 }
2477 }
2478
2479 #[test]
2484 fn prop_rustapi_nest_includes_routes_in_openapi(
2485 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2486 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2487 has_param in any::<bool>(),
2488 ) {
2489 async fn handler() -> &'static str { "handler" }
2490
2491 let prefix = format!("/{}", prefix_segments.join("/"));
2493
2494 let mut route_path = format!("/{}", route_segments.join("/"));
2496 if has_param {
2497 route_path.push_str("/{id}");
2498 }
2499
2500 let nested_router = Router::new().route(&route_path, get(handler));
2502 let app = RustApi::new().nest(&prefix, nested_router);
2503
2504 let expected_openapi_path = format!("{}{}", prefix, route_path);
2506
2507 let spec = app.openapi_spec();
2509
2510 prop_assert!(
2512 spec.paths.contains_key(&expected_openapi_path),
2513 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2514 expected_openapi_path,
2515 spec.paths.keys().collect::<Vec<_>>()
2516 );
2517
2518 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2520 prop_assert!(
2521 path_item.get.is_some(),
2522 "GET operation should exist for path '{}'",
2523 expected_openapi_path
2524 );
2525 }
2526
2527 #[test]
2532 fn prop_rustapi_nest_route_matching_identical(
2533 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2534 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2535 param_value in "[a-z0-9]{1,10}",
2536 ) {
2537 use crate::router::RouteMatch;
2538
2539 async fn handler() -> &'static str { "handler" }
2540
2541 let prefix = format!("/{}", prefix_segments.join("/"));
2543 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
2544
2545 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2547 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2548
2549 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2551 let rustapi_router = rustapi_app.into_router();
2552 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2553
2554 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
2556
2557 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
2559 let router_match = router_app.match_route(&full_path, &Method::GET);
2560
2561 match (rustapi_match, router_match) {
2563 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
2564 prop_assert_eq!(
2565 rustapi_params.len(),
2566 router_params.len(),
2567 "Parameter count should match"
2568 );
2569 for (key, value) in &router_params {
2570 prop_assert!(
2571 rustapi_params.contains_key(key),
2572 "RustApi should have parameter '{}'",
2573 key
2574 );
2575 prop_assert_eq!(
2576 rustapi_params.get(key).unwrap(),
2577 value,
2578 "Parameter '{}' value should match",
2579 key
2580 );
2581 }
2582 }
2583 (rustapi_result, router_result) => {
2584 prop_assert!(
2585 false,
2586 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
2587 match rustapi_result {
2588 RouteMatch::Found { .. } => "Found",
2589 RouteMatch::NotFound => "NotFound",
2590 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2591 },
2592 match router_result {
2593 RouteMatch::Found { .. } => "Found",
2594 RouteMatch::NotFound => "NotFound",
2595 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2596 }
2597 );
2598 }
2599 }
2600 }
2601 }
2602
2603 #[test]
2605 fn test_openapi_operations_propagated_during_nesting() {
2606 async fn list_users() -> &'static str {
2607 "list users"
2608 }
2609 async fn get_user() -> &'static str {
2610 "get user"
2611 }
2612 async fn create_user() -> &'static str {
2613 "create user"
2614 }
2615
2616 let users_router = Router::new()
2619 .route("/", get(list_users))
2620 .route("/create", post(create_user))
2621 .route("/{id}", get(get_user));
2622
2623 let app = RustApi::new().nest("/api/v1/users", users_router);
2625
2626 let spec = app.openapi_spec();
2627
2628 assert!(
2630 spec.paths.contains_key("/api/v1/users"),
2631 "Should have /api/v1/users path"
2632 );
2633 let users_path = spec.paths.get("/api/v1/users").unwrap();
2634 assert!(users_path.get.is_some(), "Should have GET operation");
2635
2636 assert!(
2638 spec.paths.contains_key("/api/v1/users/create"),
2639 "Should have /api/v1/users/create path"
2640 );
2641 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
2642 assert!(create_path.post.is_some(), "Should have POST operation");
2643
2644 assert!(
2646 spec.paths.contains_key("/api/v1/users/{id}"),
2647 "Should have /api/v1/users/{{id}} path"
2648 );
2649 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
2650 assert!(
2651 user_path.get.is_some(),
2652 "Should have GET operation for user by id"
2653 );
2654
2655 let get_user_op = user_path.get.as_ref().unwrap();
2657 assert!(!get_user_op.parameters.is_empty(), "Should have parameters");
2658 let params = &get_user_op.parameters;
2659 assert!(
2660 params
2661 .iter()
2662 .any(|p| p.name == "id" && p.location == "path"),
2663 "Should have 'id' path parameter"
2664 );
2665 }
2666
2667 #[test]
2669 fn test_openapi_spec_empty_without_routes() {
2670 let app = RustApi::new();
2671 let spec = app.openapi_spec();
2672
2673 assert!(
2675 spec.paths.is_empty(),
2676 "OpenAPI spec should have no paths without routes"
2677 );
2678 }
2679
2680 #[test]
2685 fn test_rustapi_nest_delegates_to_router_nest() {
2686 use crate::router::RouteMatch;
2687
2688 async fn list_users() -> &'static str {
2689 "list users"
2690 }
2691 async fn get_user() -> &'static str {
2692 "get user"
2693 }
2694 async fn create_user() -> &'static str {
2695 "create user"
2696 }
2697
2698 let users_router = Router::new()
2700 .route("/", get(list_users))
2701 .route("/create", post(create_user))
2702 .route("/{id}", get(get_user));
2703
2704 let app = RustApi::new().nest("/api/v1/users", users_router);
2706 let router = app.into_router();
2707
2708 let routes = router.registered_routes();
2710 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
2711
2712 assert!(
2714 routes.contains_key("/api/v1/users"),
2715 "Should have /api/v1/users route"
2716 );
2717 assert!(
2718 routes.contains_key("/api/v1/users/create"),
2719 "Should have /api/v1/users/create route"
2720 );
2721 assert!(
2722 routes.contains_key("/api/v1/users/:id"),
2723 "Should have /api/v1/users/:id route"
2724 );
2725
2726 match router.match_route("/api/v1/users", &Method::GET) {
2728 RouteMatch::Found { params, .. } => {
2729 assert!(params.is_empty(), "Root route should have no params");
2730 }
2731 _ => panic!("GET /api/v1/users should be found"),
2732 }
2733
2734 match router.match_route("/api/v1/users/create", &Method::POST) {
2735 RouteMatch::Found { params, .. } => {
2736 assert!(params.is_empty(), "Create route should have no params");
2737 }
2738 _ => panic!("POST /api/v1/users/create should be found"),
2739 }
2740
2741 match router.match_route("/api/v1/users/123", &Method::GET) {
2742 RouteMatch::Found { params, .. } => {
2743 assert_eq!(
2744 params.get("id"),
2745 Some(&"123".to_string()),
2746 "Should extract id param"
2747 );
2748 }
2749 _ => panic!("GET /api/v1/users/123 should be found"),
2750 }
2751
2752 match router.match_route("/api/v1/users", &Method::DELETE) {
2754 RouteMatch::MethodNotAllowed { allowed } => {
2755 assert!(allowed.contains(&Method::GET), "Should allow GET");
2756 }
2757 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
2758 }
2759 }
2760
2761 #[test]
2766 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
2767 async fn list_items() -> &'static str {
2768 "list items"
2769 }
2770 async fn get_item() -> &'static str {
2771 "get item"
2772 }
2773
2774 let items_router = Router::new()
2776 .route("/", get(list_items))
2777 .route("/{item_id}", get(get_item));
2778
2779 let app = RustApi::new().nest("/api/items", items_router);
2781
2782 let spec = app.openapi_spec();
2784
2785 assert!(
2787 spec.paths.contains_key("/api/items"),
2788 "Should have /api/items in OpenAPI"
2789 );
2790 assert!(
2791 spec.paths.contains_key("/api/items/{item_id}"),
2792 "Should have /api/items/{{item_id}} in OpenAPI"
2793 );
2794
2795 let list_path = spec.paths.get("/api/items").unwrap();
2797 assert!(
2798 list_path.get.is_some(),
2799 "Should have GET operation for /api/items"
2800 );
2801
2802 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
2803 assert!(
2804 get_path.get.is_some(),
2805 "Should have GET operation for /api/items/{{item_id}}"
2806 );
2807
2808 let get_op = get_path.get.as_ref().unwrap();
2810 assert!(!get_op.parameters.is_empty(), "Should have parameters");
2811 let params = &get_op.parameters;
2812 assert!(
2813 params
2814 .iter()
2815 .any(|p| p.name == "item_id" && p.location == "path"),
2816 "Should have 'item_id' path parameter"
2817 );
2818 }
2819}