1use crate::error::Result;
4use crate::events::LifecycleHooks;
5use crate::interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor};
6use crate::middleware::{
7 BodyLimitLayer, BoxedNext, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT,
8};
9use crate::response::IntoResponse;
10use crate::router::{MethodRouter, Router};
11use crate::server::Server;
12use crate::{Request, Response};
13use http::Extensions;
14use std::collections::BTreeMap;
15#[cfg(feature = "dashboard")]
16use std::collections::BTreeSet;
17use std::future::Future;
18use std::sync::Arc;
19use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
20
21#[derive(Clone)]
26pub struct RequestDispatcher {
27 router: Arc<Router>,
28 layers: LayerStack,
29 interceptors: InterceptorChain,
30}
31
32impl RequestDispatcher {
33 pub fn state_ref(&self) -> Arc<Extensions> {
36 self.router.state_ref()
37 }
38
39 pub async fn dispatch(&self, request: Request) -> Response {
44 let req = self.interceptors.intercept_request(request);
45
46 let path = req.path().to_owned();
47 let method = req.method().clone();
48
49 let response = if self.layers.is_empty() {
50 crate::server::route_request_direct(&self.router, req, &path, &method).await
51 } else {
52 let router = self.router.clone();
53 let p = path.clone();
54 let m = method.clone();
55
56 let routing_handler: BoxedNext = Arc::new(move |r: Request| {
57 let router = router.clone();
58 let pp = p.clone();
59 let mm = m.clone();
60 Box::pin(async move { crate::server::route_request(&router, r, &pp, &mm).await })
61 });
62
63 self.layers.execute(req, routing_handler).await
64 };
65
66 self.interceptors.intercept_response(response)
67 }
68}
69
70pub struct RustApi {
88 router: Router,
89 openapi_spec: rustapi_openapi::OpenApiSpec,
90 layers: LayerStack,
91 body_limit: Option<usize>,
92 interceptors: InterceptorChain,
93 lifecycle_hooks: LifecycleHooks,
94 hot_reload: bool,
95 #[cfg(feature = "http3")]
96 http3_config: Option<crate::http3::Http3Config>,
97 health_check: Option<crate::health::HealthCheck>,
98 health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
99 status_config: Option<crate::status::StatusConfig>,
100 #[cfg(feature = "dashboard")]
101 dashboard_config: Option<crate::dashboard::DashboardConfig>,
102}
103
104#[derive(Debug, Clone)]
112pub struct ProductionDefaultsConfig {
113 service_name: String,
114 version: Option<String>,
115 tracing_level: tracing::Level,
116 health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
117 enable_request_id: bool,
118 enable_tracing: bool,
119 enable_health_endpoints: bool,
120}
121
122impl ProductionDefaultsConfig {
123 pub fn new(service_name: impl Into<String>) -> Self {
125 Self {
126 service_name: service_name.into(),
127 version: None,
128 tracing_level: tracing::Level::INFO,
129 health_endpoint_config: None,
130 enable_request_id: true,
131 enable_tracing: true,
132 enable_health_endpoints: true,
133 }
134 }
135
136 pub fn version(mut self, version: impl Into<String>) -> Self {
138 self.version = Some(version.into());
139 self
140 }
141
142 pub fn tracing_level(mut self, level: tracing::Level) -> Self {
144 self.tracing_level = level;
145 self
146 }
147
148 pub fn health_endpoint_config(mut self, config: crate::health::HealthEndpointConfig) -> Self {
150 self.health_endpoint_config = Some(config);
151 self
152 }
153
154 pub fn request_id(mut self, enabled: bool) -> Self {
156 self.enable_request_id = enabled;
157 self
158 }
159
160 pub fn tracing(mut self, enabled: bool) -> Self {
162 self.enable_tracing = enabled;
163 self
164 }
165
166 pub fn health_endpoints(mut self, enabled: bool) -> Self {
168 self.enable_health_endpoints = enabled;
169 self
170 }
171}
172
173impl RustApi {
174 pub fn new() -> Self {
176 let _ = tracing_subscriber::registry()
178 .with(
179 EnvFilter::try_from_default_env()
180 .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
181 )
182 .with(tracing_subscriber::fmt::layer())
183 .try_init();
184
185 Self {
186 router: Router::new(),
187 openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
188 .register::<rustapi_openapi::ErrorSchema>()
189 .register::<rustapi_openapi::ErrorBodySchema>()
190 .register::<rustapi_openapi::ValidationErrorSchema>()
191 .register::<rustapi_openapi::ValidationErrorBodySchema>()
192 .register::<rustapi_openapi::FieldErrorSchema>(),
193 layers: LayerStack::new(),
194 body_limit: Some(DEFAULT_BODY_LIMIT), interceptors: InterceptorChain::new(),
196 lifecycle_hooks: LifecycleHooks::new(),
197 hot_reload: false,
198 #[cfg(feature = "http3")]
199 http3_config: None,
200 health_check: None,
201 health_endpoint_config: None,
202 status_config: None,
203 #[cfg(feature = "dashboard")]
204 dashboard_config: None,
205 }
206 }
207
208 #[cfg(feature = "swagger-ui")]
238 pub fn auto() -> Self {
239 Self::new().mount_auto_routes_grouped().docs("/docs")
240 }
241
242 #[cfg(not(feature = "swagger-ui"))]
243 pub fn auto() -> Self {
244 Self::new().mount_auto_routes_grouped()
245 }
246
247 pub fn config() -> RustApiConfig {
265 RustApiConfig::new()
266 }
267
268 pub fn body_limit(mut self, limit: usize) -> Self {
289 self.body_limit = Some(limit);
290 self
291 }
292
293 pub fn no_body_limit(mut self) -> Self {
306 self.body_limit = None;
307 self
308 }
309
310 pub fn layer<L>(mut self, layer: L) -> Self
330 where
331 L: MiddlewareLayer,
332 {
333 self.layers.push(Box::new(layer));
334 self
335 }
336
337 pub fn request_interceptor<I>(mut self, interceptor: I) -> Self
369 where
370 I: RequestInterceptor,
371 {
372 self.interceptors.add_request_interceptor(interceptor);
373 self
374 }
375
376 pub fn response_interceptor<I>(mut self, interceptor: I) -> Self
408 where
409 I: ResponseInterceptor,
410 {
411 self.interceptors.add_response_interceptor(interceptor);
412 self
413 }
414
415 pub fn state<S>(self, _state: S) -> Self
431 where
432 S: Clone + Send + Sync + 'static,
433 {
434 let state = _state;
436 let mut app = self;
437 let r = std::mem::take(&mut app.router);
438 app.router = r.state(state);
439 app
440 }
441
442 pub fn on_start<F, Fut>(mut self, hook: F) -> Self
459 where
460 F: FnOnce() -> Fut + Send + 'static,
461 Fut: Future<Output = ()> + Send + 'static,
462 {
463 self.lifecycle_hooks
464 .on_start
465 .push(Box::new(move || Box::pin(hook())));
466 self
467 }
468
469 pub fn on_shutdown<F, Fut>(mut self, hook: F) -> Self
486 where
487 F: FnOnce() -> Fut + Send + 'static,
488 Fut: Future<Output = ()> + Send + 'static,
489 {
490 self.lifecycle_hooks
491 .on_shutdown
492 .push(Box::new(move || Box::pin(hook())));
493 self
494 }
495
496 pub fn hot_reload(mut self, enabled: bool) -> Self {
515 self.hot_reload = enabled;
516 self
517 }
518
519 pub fn register_schema<T: rustapi_openapi::schema::RustApiSchema>(mut self) -> Self {
531 self.openapi_spec = self.openapi_spec.register::<T>();
532 self
533 }
534
535 pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
537 self.openapi_spec.info.title = title.to_string();
540 self.openapi_spec.info.version = version.to_string();
541 self.openapi_spec.info.description = description.map(|d| d.to_string());
542 self
543 }
544
545 pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
547 &self.openapi_spec
548 }
549
550 fn mount_auto_routes_grouped(mut self) -> Self {
551 let routes = crate::auto_route::collect_auto_routes();
552
553 if routes.is_empty() {
554 tracing::warn!(
557 target: "rustapi::auto",
558 count = 0,
559 "RustApi::auto() collected 0 routes. \
560 This usually means either:\n\
561 - No handlers were annotated with #[rustapi_rs::get], #[post], etc.\n\
562 - The binary/test was not linked with the annotated modules (common in some test setups).\n\
563 - You are building a library (cdylib/rlib) where linkme distributed slices may not be populated.\n\n\
564 You can still register routes manually with .route() or check with rustapi_rs::auto_route_count()."
565 );
566 } else {
567 #[cfg(feature = "tracing")]
568 tracing::debug!(
569 target: "rustapi::auto",
570 count = routes.len(),
571 "Auto route collection found handlers"
572 );
573 }
574
575 let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();
577
578 for route in routes {
579 let crate::handler::Route {
580 path: route_path,
581 method,
582 handler,
583 operation,
584 component_registrar,
585 ..
586 } = route;
587
588 let method_enum = match method {
589 "GET" => http::Method::GET,
590 "POST" => http::Method::POST,
591 "PUT" => http::Method::PUT,
592 "DELETE" => http::Method::DELETE,
593 "PATCH" => http::Method::PATCH,
594 _ => http::Method::GET,
595 };
596
597 let path = if route_path.starts_with('/') {
598 route_path.to_string()
599 } else {
600 format!("/{}", route_path)
601 };
602
603 let entry = by_path.entry(path).or_default();
604 entry.insert_boxed_with_operation(method_enum, handler, operation, component_registrar);
605 }
606
607 #[cfg(feature = "tracing")]
608 let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
609 #[cfg(feature = "tracing")]
610 let path_count = by_path.len();
611
612 for (path, method_router) in by_path {
613 self = self.route(&path, method_router);
614 }
615
616 crate::trace_info!(
617 paths = path_count,
618 routes = route_count,
619 "Auto-registered routes"
620 );
621
622 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
624
625 self
626 }
627
628 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
639 for register_components in &method_router.component_registrars {
640 register_components(&mut self.openapi_spec);
641 }
642
643 for (method, op) in &method_router.operations {
645 let mut op = op.clone();
646 add_path_params_to_operation(path, &mut op, &BTreeMap::new());
647 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
648 }
649
650 self.router = self.router.route(path, method_router);
651 self
652 }
653
654 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
656 self.route(P::PATH, method_router)
657 }
658
659 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
663 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
664 self.route(path, method_router)
665 }
666
667 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
685 let method_enum = match route.method {
686 "GET" => http::Method::GET,
687 "POST" => http::Method::POST,
688 "PUT" => http::Method::PUT,
689 "DELETE" => http::Method::DELETE,
690 "PATCH" => http::Method::PATCH,
691 _ => http::Method::GET,
692 };
693
694 (route.component_registrar)(&mut self.openapi_spec);
695
696 let mut op = route.operation;
698 add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
699 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
700
701 self.route_with_method(route.path, method_enum, route.handler)
702 }
703
704 fn route_with_method(
706 self,
707 path: &str,
708 method: http::Method,
709 handler: crate::handler::BoxedHandler,
710 ) -> Self {
711 use crate::router::MethodRouter;
712 let path = if !path.starts_with('/') {
721 format!("/{}", path)
722 } else {
723 path.to_string()
724 };
725
726 let mut handlers = std::collections::HashMap::new();
735 handlers.insert(method, handler);
736
737 let method_router = MethodRouter::from_boxed(handlers);
738 self.route(&path, method_router)
739 }
740
741 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
757 let normalized_prefix = normalize_prefix_for_openapi(prefix);
759
760 for (matchit_path, method_router) in router.method_routers() {
763 for register_components in &method_router.component_registrars {
764 register_components(&mut self.openapi_spec);
765 }
766
767 let display_path = router
769 .registered_routes()
770 .get(matchit_path)
771 .map(|info| info.path.clone())
772 .unwrap_or_else(|| matchit_path.clone());
773
774 let prefixed_path = if display_path == "/" {
776 normalized_prefix.clone()
777 } else {
778 format!("{}{}", normalized_prefix, display_path)
779 };
780
781 for (method, op) in &method_router.operations {
783 let mut op = op.clone();
784 add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
785 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
786 }
787 }
788
789 self.router = self.router.nest(prefix, router);
791 self
792 }
793
794 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
823 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
824 }
825
826 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
843 use crate::router::MethodRouter;
844 use std::collections::HashMap;
845
846 let prefix = config.prefix.clone();
847 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
848
849 let handler: crate::handler::BoxedHandler =
851 std::sync::Arc::new(move |req: crate::Request| {
852 let config = config.clone();
853 let path = req.uri().path().to_string();
854
855 Box::pin(async move {
856 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
857
858 match crate::static_files::StaticFile::serve(relative_path, &config).await {
859 Ok(response) => response,
860 Err(err) => err.into_response(),
861 }
862 })
863 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
864 });
865
866 let mut handlers = HashMap::new();
867 handlers.insert(http::Method::GET, handler);
868 let method_router = MethodRouter::from_boxed(handlers);
869
870 self.route(&catch_all_path, method_router)
871 }
872
873 #[cfg(feature = "compression")]
890 pub fn compression(self) -> Self {
891 self.layer(crate::middleware::CompressionLayer::new())
892 }
893
894 #[cfg(feature = "compression")]
910 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
911 self.layer(crate::middleware::CompressionLayer::with_config(config))
912 }
913
914 #[cfg(feature = "swagger-ui")]
938 pub fn docs(self, path: &str) -> Self {
939 let title = self.openapi_spec.info.title.clone();
940 let version = self.openapi_spec.info.version.clone();
941 let description = self.openapi_spec.info.description.clone();
942
943 self.docs_with_info(path, &title, &version, description.as_deref())
944 }
945
946 #[cfg(feature = "swagger-ui")]
955 pub fn docs_with_info(
956 mut self,
957 path: &str,
958 title: &str,
959 version: &str,
960 description: Option<&str>,
961 ) -> Self {
962 use crate::router::get;
963 self.openapi_spec.info.title = title.to_string();
965 self.openapi_spec.info.version = version.to_string();
966 if let Some(desc) = description {
967 self.openapi_spec.info.description = Some(desc.to_string());
968 }
969
970 let path = path.trim_end_matches('/');
971 let openapi_path = format!("{}/openapi.json", path);
972
973 let spec_value = self.openapi_spec.to_json();
975 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
976 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
978 "{}".to_string()
979 });
980 let openapi_url = openapi_path.clone();
981
982 let spec_handler = move || {
984 let json = spec_json.clone();
985 async move {
986 http::Response::builder()
987 .status(http::StatusCode::OK)
988 .header(http::header::CONTENT_TYPE, "application/json")
989 .body(crate::response::Body::from(json))
990 .unwrap_or_else(|e| {
991 tracing::error!("Failed to build response: {}", e);
992 http::Response::builder()
993 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
994 .body(crate::response::Body::from("Internal Server Error"))
995 .unwrap()
996 })
997 }
998 };
999
1000 let docs_handler = move || {
1002 let url = openapi_url.clone();
1003 async move {
1004 let response = rustapi_openapi::swagger_ui_html(&url);
1005 response.map(crate::response::Body::Full)
1006 }
1007 };
1008
1009 self.route(&openapi_path, get(spec_handler))
1010 .route(path, get(docs_handler))
1011 }
1012
1013 #[cfg(feature = "swagger-ui")]
1029 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
1030 let title = self.openapi_spec.info.title.clone();
1031 let version = self.openapi_spec.info.version.clone();
1032 let description = self.openapi_spec.info.description.clone();
1033
1034 self.docs_with_auth_and_info(
1035 path,
1036 username,
1037 password,
1038 &title,
1039 &version,
1040 description.as_deref(),
1041 )
1042 }
1043
1044 #[cfg(feature = "swagger-ui")]
1060 pub fn docs_with_auth_and_info(
1061 mut self,
1062 path: &str,
1063 username: &str,
1064 password: &str,
1065 title: &str,
1066 version: &str,
1067 description: Option<&str>,
1068 ) -> Self {
1069 use crate::router::MethodRouter;
1070 use std::collections::HashMap;
1071
1072 #[inline]
1073 fn base64_encode(input: &[u8]) -> String {
1074 const ALPHA: &[u8; 64] =
1075 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1076 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1077 for chunk in input.chunks(3) {
1078 let b0 = chunk[0] as usize;
1079 let b1 = if chunk.len() > 1 {
1080 chunk[1] as usize
1081 } else {
1082 0
1083 };
1084 let b2 = if chunk.len() > 2 {
1085 chunk[2] as usize
1086 } else {
1087 0
1088 };
1089 out.push(ALPHA[b0 >> 2] as char);
1090 out.push(ALPHA[((b0 & 3) << 4) | (b1 >> 4)] as char);
1091 out.push(if chunk.len() > 1 {
1092 ALPHA[((b1 & 0xf) << 2) | (b2 >> 6)] as char
1093 } else {
1094 '='
1095 });
1096 out.push(if chunk.len() > 2 {
1097 ALPHA[b2 & 63] as char
1098 } else {
1099 '='
1100 });
1101 }
1102 out
1103 }
1104
1105 self.openapi_spec.info.title = title.to_string();
1107 self.openapi_spec.info.version = version.to_string();
1108 if let Some(desc) = description {
1109 self.openapi_spec.info.description = Some(desc.to_string());
1110 }
1111
1112 let path = path.trim_end_matches('/');
1113 let openapi_path = format!("{}/openapi.json", path);
1114
1115 let credentials = format!("{}:{}", username, password);
1117 let encoded = base64_encode(credentials.as_bytes());
1118 let expected_auth = format!("Basic {}", encoded);
1119
1120 let spec_value = self.openapi_spec.to_json();
1122 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
1123 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
1124 "{}".to_string()
1125 });
1126 let openapi_url = openapi_path.clone();
1127 let expected_auth_spec = expected_auth.clone();
1128 let expected_auth_docs = expected_auth;
1129
1130 let spec_handler: crate::handler::BoxedHandler =
1132 std::sync::Arc::new(move |req: crate::Request| {
1133 let json = spec_json.clone();
1134 let expected = expected_auth_spec.clone();
1135 Box::pin(async move {
1136 if !check_basic_auth(&req, &expected) {
1137 return unauthorized_response();
1138 }
1139 http::Response::builder()
1140 .status(http::StatusCode::OK)
1141 .header(http::header::CONTENT_TYPE, "application/json")
1142 .body(crate::response::Body::from(json))
1143 .unwrap_or_else(|e| {
1144 tracing::error!("Failed to build response: {}", e);
1145 http::Response::builder()
1146 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1147 .body(crate::response::Body::from("Internal Server Error"))
1148 .unwrap()
1149 })
1150 })
1151 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1152 });
1153
1154 let docs_handler: crate::handler::BoxedHandler =
1156 std::sync::Arc::new(move |req: crate::Request| {
1157 let url = openapi_url.clone();
1158 let expected = expected_auth_docs.clone();
1159 Box::pin(async move {
1160 if !check_basic_auth(&req, &expected) {
1161 return unauthorized_response();
1162 }
1163 let response = rustapi_openapi::swagger_ui_html(&url);
1164 response.map(crate::response::Body::Full)
1165 })
1166 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1167 });
1168
1169 let mut spec_handlers = HashMap::new();
1171 spec_handlers.insert(http::Method::GET, spec_handler);
1172 let spec_router = MethodRouter::from_boxed(spec_handlers);
1173
1174 let mut docs_handlers = HashMap::new();
1175 docs_handlers.insert(http::Method::GET, docs_handler);
1176 let docs_router = MethodRouter::from_boxed(docs_handlers);
1177
1178 self.route(&openapi_path, spec_router)
1179 .route(path, docs_router)
1180 }
1181
1182 pub fn status_page(self) -> Self {
1184 self.status_page_with_config(crate::status::StatusConfig::default())
1185 }
1186
1187 pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self {
1189 self.status_config = Some(config);
1190 self
1191 }
1192
1193 pub fn health_endpoints(mut self) -> Self {
1198 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1199 if self.health_check.is_none() {
1200 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1201 }
1202 self
1203 }
1204
1205 pub fn health_endpoints_with_config(
1207 mut self,
1208 config: crate::health::HealthEndpointConfig,
1209 ) -> Self {
1210 self.health_endpoint_config = Some(config);
1211 if self.health_check.is_none() {
1212 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1213 }
1214 self
1215 }
1216
1217 pub fn with_health_check(mut self, health_check: crate::health::HealthCheck) -> Self {
1222 self.health_check = Some(health_check);
1223 if self.health_endpoint_config.is_none() {
1224 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1225 }
1226 self
1227 }
1228
1229 pub fn production_defaults(self, service_name: impl Into<String>) -> Self {
1236 self.production_defaults_with_config(ProductionDefaultsConfig::new(service_name))
1237 }
1238
1239 pub fn production_defaults_with_config(mut self, config: ProductionDefaultsConfig) -> Self {
1241 if config.enable_request_id {
1242 self = self.layer(crate::middleware::RequestIdLayer::new());
1243 }
1244
1245 if config.enable_tracing {
1246 let mut tracing_layer =
1247 crate::middleware::TracingLayer::with_level(config.tracing_level)
1248 .with_field("service", config.service_name.clone())
1249 .with_field("environment", crate::error::get_environment().to_string());
1250
1251 if let Some(version) = &config.version {
1252 tracing_layer = tracing_layer.with_field("version", version.clone());
1253 }
1254
1255 self = self.layer(tracing_layer);
1256 }
1257
1258 if config.enable_health_endpoints {
1259 if self.health_check.is_none() {
1260 let mut builder = crate::health::HealthCheckBuilder::default();
1261 if let Some(version) = &config.version {
1262 builder = builder.version(version.clone());
1263 }
1264 self.health_check = Some(builder.build());
1265 }
1266
1267 if self.health_endpoint_config.is_none() {
1268 self.health_endpoint_config =
1269 Some(config.health_endpoint_config.unwrap_or_default());
1270 }
1271 }
1272
1273 self
1274 }
1275
1276 fn print_hot_reload_banner(&self, addr: &str) {
1278 if !self.hot_reload {
1279 return;
1280 }
1281
1282 std::env::set_var("RUSTAPI_HOT_RELOAD", "1");
1284
1285 let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD")
1286 .map(|v| v == "1")
1287 .unwrap_or(false);
1288
1289 tracing::info!("🔄 Hot-reload mode enabled");
1290
1291 if is_under_watcher {
1292 tracing::info!(" File watcher active — changes will trigger rebuild + restart");
1293 } else {
1294 tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload");
1295 }
1296
1297 tracing::info!(" Listening on http://{addr}");
1298 }
1299
1300 fn apply_health_endpoints(&mut self) {
1302 if let Some(config) = &self.health_endpoint_config {
1303 use crate::router::get;
1304
1305 let health_check = self
1306 .health_check
1307 .clone()
1308 .unwrap_or_else(|| crate::health::HealthCheckBuilder::default().build());
1309
1310 let health_path = config.health_path.clone();
1311 let readiness_path = config.readiness_path.clone();
1312 let liveness_path = config.liveness_path.clone();
1313
1314 let health_handler = {
1315 let health_check = health_check.clone();
1316 move || {
1317 let health_check = health_check.clone();
1318 async move { crate::health::health_response(health_check).await }
1319 }
1320 };
1321
1322 let readiness_handler = {
1323 let health_check = health_check.clone();
1324 move || {
1325 let health_check = health_check.clone();
1326 async move { crate::health::readiness_response(health_check).await }
1327 }
1328 };
1329
1330 let liveness_handler = || async { crate::health::liveness_response().await };
1331
1332 let router = std::mem::take(&mut self.router);
1333 self.router = router
1334 .route(&health_path, get(health_handler))
1335 .route(&readiness_path, get(readiness_handler))
1336 .route(&liveness_path, get(liveness_handler));
1337 }
1338 }
1339
1340 fn apply_status_page(&mut self) {
1341 if let Some(config) = &self.status_config {
1342 let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new());
1343
1344 self.layers
1346 .push(Box::new(crate::status::StatusLayer::new(monitor.clone())));
1347
1348 use crate::router::MethodRouter;
1350 use std::collections::HashMap;
1351
1352 let monitor = monitor.clone();
1353 let config = config.clone();
1354 let path = config.path.clone(); let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| {
1357 let monitor = monitor.clone();
1358 let config = config.clone();
1359 Box::pin(async move {
1360 crate::status::status_handler(monitor, config)
1361 .await
1362 .into_response()
1363 })
1364 });
1365
1366 let mut handlers = HashMap::new();
1367 handlers.insert(http::Method::GET, handler);
1368 let method_router = MethodRouter::from_boxed(handlers);
1369
1370 let router = std::mem::take(&mut self.router);
1372 self.router = router.route(&path, method_router);
1373 }
1374 }
1375
1376 #[cfg(feature = "dashboard")]
1377 fn apply_dashboard(&mut self) {
1378 use crate::dashboard::{DashboardMetrics, RouteInventoryItem};
1379 use crate::handler::BoxedHandler;
1380 use crate::response::Body;
1381 use crate::router::MethodRouter;
1382 use std::collections::HashMap;
1383 use std::sync::Arc;
1384
1385 let mut config = match self.dashboard_config.take() {
1386 Some(c) => c,
1387 None => return,
1388 };
1389 config.normalize_paths();
1390
1391 let mut inventory: Vec<RouteInventoryItem> = self
1395 .router
1396 .registered_routes()
1397 .values()
1398 .map(|info| {
1399 let methods: Vec<String> = info.methods.iter().map(|m| m.to_string()).collect();
1400 let health_eligible = self
1401 .health_endpoint_config
1402 .as_ref()
1403 .map(|health| {
1404 info.path == health.health_path
1405 || info.path == health.readiness_path
1406 || info.path == health.liveness_path
1407 })
1408 .unwrap_or(false);
1409
1410 RouteInventoryItem::new(info.path.clone(), methods)
1411 .with_tags(openapi_tags_for_route(
1412 &self.openapi_spec,
1413 &info.path,
1414 &info.methods,
1415 ))
1416 .with_feature_gates(infer_route_feature_gates(&info.path))
1417 .health_eligible(health_eligible)
1418 .replay_eligible(is_dashboard_replay_eligible(&info.path, health_eligible))
1419 })
1420 .collect();
1421 inventory.sort_by(|a, b| a.path.cmp(&b.path));
1422
1423 let metrics = Arc::new(DashboardMetrics::new_with_replay_admin_path(
1424 inventory,
1425 config.replay_api_path.clone(),
1426 ));
1427
1428 let router = std::mem::take(&mut self.router);
1430 self.router = router.state(Arc::clone(&metrics));
1431
1432 let prefix = config.path.trim_end_matches('/').to_owned();
1434
1435 fn not_found() -> crate::response::Response {
1436 http::Response::builder()
1437 .status(404)
1438 .body(Body::Full(http_body_util::Full::new(bytes::Bytes::from(
1439 "Not Found",
1440 ))))
1441 .unwrap()
1442 }
1443
1444 {
1446 let metrics_c = Arc::clone(&metrics);
1447 let config_c = config.clone();
1448 let handler: BoxedHandler = Arc::new(move |req| {
1449 let metrics = Arc::clone(&metrics_c);
1450 let cfg = config_c.clone();
1451 Box::pin(async move {
1452 let headers = req.headers().clone();
1453 let method = req.method().to_string();
1454 let path = req.uri().path().to_owned();
1455 crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg)
1456 .await
1457 .unwrap_or_else(not_found)
1458 })
1459 });
1460 let mut h = HashMap::new();
1461 h.insert(http::Method::GET, handler);
1462 let router = std::mem::take(&mut self.router);
1463 self.router = router.route(&prefix, MethodRouter::from_boxed(h));
1464 }
1465
1466 {
1468 let metrics_c = Arc::clone(&metrics);
1469 let config_c = config.clone();
1470 let wildcard_path = format!("{}/*path", prefix);
1471 let handler: BoxedHandler = Arc::new(move |req| {
1472 let metrics = Arc::clone(&metrics_c);
1473 let cfg = config_c.clone();
1474 Box::pin(async move {
1475 let headers = req.headers().clone();
1476 let method = req.method().to_string();
1477 let path = req.uri().path().to_owned();
1478 crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg)
1479 .await
1480 .unwrap_or_else(not_found)
1481 })
1482 });
1483 let mut h = HashMap::new();
1484 h.insert(http::Method::GET, handler);
1485 let router = std::mem::take(&mut self.router);
1486 self.router = router.route(&wildcard_path, MethodRouter::from_boxed(h));
1487 }
1488 }
1489
1490 #[cfg(feature = "dashboard")]
1510 pub fn dashboard(mut self, config: crate::dashboard::DashboardConfig) -> Self {
1511 self.dashboard_config = Some(config);
1512 self
1513 }
1514
1515 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1526 self.print_hot_reload_banner(addr);
1528
1529 self.apply_health_endpoints();
1531
1532 self.apply_status_page();
1534
1535 #[cfg(feature = "dashboard")]
1537 self.apply_dashboard();
1538
1539 if let Some(limit) = self.body_limit {
1541 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1543 }
1544
1545 for hook in self.lifecycle_hooks.on_start {
1547 hook().await;
1548 }
1549
1550 let server = Server::new(self.router, self.layers, self.interceptors);
1551 server.run(addr).await
1552 }
1553
1554 pub async fn run_with_shutdown<F>(
1556 mut self,
1557 addr: impl AsRef<str>,
1558 signal: F,
1559 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1560 where
1561 F: std::future::Future<Output = ()> + Send + 'static,
1562 {
1563 self.print_hot_reload_banner(addr.as_ref());
1565
1566 self.apply_health_endpoints();
1568
1569 self.apply_status_page();
1571
1572 #[cfg(feature = "dashboard")]
1574 self.apply_dashboard();
1575
1576 if let Some(limit) = self.body_limit {
1577 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1578 }
1579
1580 for hook in self.lifecycle_hooks.on_start {
1582 hook().await;
1583 }
1584
1585 let shutdown_hooks = self.lifecycle_hooks.on_shutdown;
1587 let wrapped_signal = async move {
1588 signal.await;
1589 for hook in shutdown_hooks {
1591 hook().await;
1592 }
1593 };
1594
1595 let server = Server::new(self.router, self.layers, self.interceptors);
1596 server
1597 .run_with_shutdown(addr.as_ref(), wrapped_signal)
1598 .await
1599 }
1600
1601 pub fn into_router(self) -> Router {
1603 self.router
1604 }
1605
1606 pub fn router(&self) -> &Router {
1608 &self.router
1609 }
1610
1611 pub fn layers(&self) -> &LayerStack {
1613 &self.layers
1614 }
1615
1616 pub fn interceptors(&self) -> &InterceptorChain {
1618 &self.interceptors
1619 }
1620
1621 pub fn request_dispatcher(&self) -> RequestDispatcher {
1627 RequestDispatcher {
1628 router: Arc::new(self.router.clone()),
1629 layers: self.layers().clone(),
1630 interceptors: self.interceptors().clone(),
1631 }
1632 }
1633
1634 #[cfg(feature = "http3")]
1648 pub async fn run_http3(
1649 mut self,
1650 config: crate::http3::Http3Config,
1651 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1652 use std::sync::Arc;
1653
1654 self.apply_health_endpoints();
1656
1657 self.apply_status_page();
1659
1660 if let Some(limit) = self.body_limit {
1662 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1663 }
1664
1665 let server = crate::http3::Http3Server::new(
1666 &config,
1667 Arc::new(self.router.clone()),
1668 Arc::new(self.layers.clone()),
1669 Arc::new(self.interceptors.clone()),
1670 )
1671 .await?;
1672
1673 server.run().await
1674 }
1675
1676 #[cfg(feature = "http3-dev")]
1690 pub async fn run_http3_dev(
1691 mut self,
1692 addr: &str,
1693 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1694 use std::sync::Arc;
1695
1696 self.apply_health_endpoints();
1698
1699 self.apply_status_page();
1701
1702 if let Some(limit) = self.body_limit {
1704 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1705 }
1706
1707 let server = crate::http3::Http3Server::new_with_self_signed(
1708 addr,
1709 Arc::new(self.router.clone()),
1710 Arc::new(self.layers.clone()),
1711 Arc::new(self.interceptors.clone()),
1712 )
1713 .await?;
1714
1715 server.run().await
1716 }
1717
1718 #[cfg(feature = "http3")]
1729 pub fn with_http3(mut self, cert_path: impl Into<String>, key_path: impl Into<String>) -> Self {
1730 self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path));
1731 self
1732 }
1733
1734 #[cfg(feature = "http3")]
1749 pub async fn run_dual_stack(
1750 mut self,
1751 http_addr: &str,
1752 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1753 use std::sync::Arc;
1754
1755 let mut config = self
1756 .http3_config
1757 .take()
1758 .ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
1759
1760 let http_socket: std::net::SocketAddr = http_addr.parse()?;
1761 config.bind_addr = if http_socket.ip().is_ipv6() {
1762 format!("[{}]", http_socket.ip())
1763 } else {
1764 http_socket.ip().to_string()
1765 };
1766 config.port = http_socket.port();
1767 let http_addr = http_socket.to_string();
1768
1769 self.apply_health_endpoints();
1771
1772 self.apply_status_page();
1774
1775 if let Some(limit) = self.body_limit {
1777 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1778 }
1779
1780 let router = Arc::new(self.router);
1781 let layers = Arc::new(self.layers);
1782 let interceptors = Arc::new(self.interceptors);
1783
1784 let http1_server =
1785 Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1786 let http3_server =
1787 crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1788
1789 tracing::info!(
1790 http1_addr = %http_addr,
1791 http3_addr = %config.socket_addr(),
1792 "Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1793 );
1794
1795 tokio::try_join!(
1796 http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1797 http3_server.run_with_shutdown(std::future::pending::<()>()),
1798 )?;
1799
1800 Ok(())
1801 }
1802}
1803
1804#[cfg(feature = "dashboard")]
1805fn openapi_tags_for_route(
1806 spec: &rustapi_openapi::OpenApiSpec,
1807 path: &str,
1808 methods: &[http::Method],
1809) -> Vec<String> {
1810 let Some(path_item) = spec.paths.get(path) else {
1811 return Vec::new();
1812 };
1813
1814 let mut tags = BTreeSet::new();
1815 for method in methods {
1816 if let Some(operation) = operation_for_method(path_item, method) {
1817 tags.extend(operation.tags.iter().cloned());
1818 }
1819 }
1820
1821 tags.into_iter().collect()
1822}
1823
1824#[cfg(feature = "dashboard")]
1825fn operation_for_method<'a>(
1826 path_item: &'a rustapi_openapi::PathItem,
1827 method: &http::Method,
1828) -> Option<&'a rustapi_openapi::Operation> {
1829 match *method {
1830 http::Method::GET => path_item.get.as_ref(),
1831 http::Method::POST => path_item.post.as_ref(),
1832 http::Method::PUT => path_item.put.as_ref(),
1833 http::Method::PATCH => path_item.patch.as_ref(),
1834 http::Method::DELETE => path_item.delete.as_ref(),
1835 http::Method::HEAD => path_item.head.as_ref(),
1836 http::Method::OPTIONS => path_item.options.as_ref(),
1837 http::Method::TRACE => path_item.trace.as_ref(),
1838 _ => None,
1839 }
1840}
1841
1842#[cfg(feature = "dashboard")]
1843fn infer_route_feature_gates(path: &str) -> Vec<String> {
1844 if path.contains("openapi") || path.contains("docs") {
1845 vec!["core-openapi".to_string()]
1846 } else if path.starts_with("/__rustapi/replays") {
1847 vec!["extras-replay".to_string()]
1848 } else {
1849 Vec::new()
1850 }
1851}
1852
1853#[cfg(feature = "dashboard")]
1854fn is_dashboard_replay_eligible(path: &str, health_eligible: bool) -> bool {
1855 !health_eligible && !path.starts_with("/__rustapi/")
1856}
1857
1858fn add_path_params_to_operation(
1859 path: &str,
1860 op: &mut rustapi_openapi::Operation,
1861 param_schemas: &BTreeMap<String, String>,
1862) {
1863 let mut params: Vec<String> = Vec::new();
1864 let mut in_brace = false;
1865 let mut current = String::new();
1866
1867 for ch in path.chars() {
1868 match ch {
1869 '{' => {
1870 in_brace = true;
1871 current.clear();
1872 }
1873 '}' => {
1874 if in_brace {
1875 in_brace = false;
1876 if !current.is_empty() {
1877 params.push(current.clone());
1878 }
1879 }
1880 }
1881 _ => {
1882 if in_brace {
1883 current.push(ch);
1884 }
1885 }
1886 }
1887 }
1888
1889 if params.is_empty() {
1890 return;
1891 }
1892
1893 let op_params = &mut op.parameters;
1894
1895 for name in params {
1896 let already = op_params
1897 .iter()
1898 .any(|p| p.location == "path" && p.name == name);
1899 if already {
1900 continue;
1901 }
1902
1903 let schema = if let Some(schema_type) = param_schemas.get(&name) {
1905 schema_type_to_openapi_schema(schema_type)
1906 } else {
1907 infer_path_param_schema(&name)
1908 };
1909
1910 op_params.push(rustapi_openapi::Parameter {
1911 name,
1912 location: "path".to_string(),
1913 required: true,
1914 description: None,
1915 deprecated: None,
1916 schema: Some(schema),
1917 });
1918 }
1919}
1920
1921fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef {
1923 match schema_type.to_lowercase().as_str() {
1924 "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1925 "type": "string",
1926 "format": "uuid"
1927 })),
1928 "integer" | "int" | "int64" | "i64" => {
1929 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1930 "type": "integer",
1931 "format": "int64"
1932 }))
1933 }
1934 "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1935 "type": "integer",
1936 "format": "int32"
1937 })),
1938 "number" | "float" | "f64" | "f32" => {
1939 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1940 "type": "number"
1941 }))
1942 }
1943 "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1944 "type": "boolean"
1945 })),
1946 _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1947 "type": "string"
1948 })),
1949 }
1950}
1951
1952fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
1961 let lower = name.to_lowercase();
1962
1963 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
1965
1966 if is_uuid {
1967 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1968 "type": "string",
1969 "format": "uuid"
1970 }));
1971 }
1972
1973 let is_integer = lower == "page"
1976 || lower == "limit"
1977 || lower == "offset"
1978 || lower == "count"
1979 || lower.ends_with("_count")
1980 || lower.ends_with("_num")
1981 || lower == "year"
1982 || lower == "month"
1983 || lower == "day"
1984 || lower == "index"
1985 || lower == "position";
1986
1987 if is_integer {
1988 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1989 "type": "integer",
1990 "format": "int64"
1991 }))
1992 } else {
1993 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
1994 }
1995}
1996
1997fn normalize_prefix_for_openapi(prefix: &str) -> String {
2004 if prefix.is_empty() {
2006 return "/".to_string();
2007 }
2008
2009 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
2011
2012 if segments.is_empty() {
2014 return "/".to_string();
2015 }
2016
2017 let mut result = String::with_capacity(prefix.len() + 1);
2019 for segment in segments {
2020 result.push('/');
2021 result.push_str(segment);
2022 }
2023
2024 result
2025}
2026
2027impl Default for RustApi {
2028 fn default() -> Self {
2029 Self::new()
2030 }
2031}
2032
2033#[cfg(feature = "swagger-ui")]
2035fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
2036 req.headers()
2037 .get(http::header::AUTHORIZATION)
2038 .and_then(|v| v.to_str().ok())
2039 .map(|auth| auth == expected)
2040 .unwrap_or(false)
2041}
2042
2043#[cfg(feature = "swagger-ui")]
2045fn unauthorized_response() -> crate::Response {
2046 http::Response::builder()
2047 .status(http::StatusCode::UNAUTHORIZED)
2048 .header(
2049 http::header::WWW_AUTHENTICATE,
2050 "Basic realm=\"API Documentation\"",
2051 )
2052 .header(http::header::CONTENT_TYPE, "text/plain")
2053 .body(crate::response::Body::from("Unauthorized"))
2054 .unwrap()
2055}
2056
2057pub struct RustApiConfig {
2059 docs_path: Option<String>,
2060 docs_enabled: bool,
2061 api_title: String,
2062 api_version: String,
2063 api_description: Option<String>,
2064 body_limit: Option<usize>,
2065 layers: LayerStack,
2066}
2067
2068impl Default for RustApiConfig {
2069 fn default() -> Self {
2070 Self::new()
2071 }
2072}
2073
2074impl RustApiConfig {
2075 pub fn new() -> Self {
2076 Self {
2077 docs_path: Some("/docs".to_string()),
2078 docs_enabled: true,
2079 api_title: "RustAPI".to_string(),
2080 api_version: "1.0.0".to_string(),
2081 api_description: None,
2082 body_limit: None,
2083 layers: LayerStack::new(),
2084 }
2085 }
2086
2087 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
2089 self.docs_path = Some(path.into());
2090 self
2091 }
2092
2093 pub fn docs_enabled(mut self, enabled: bool) -> Self {
2095 self.docs_enabled = enabled;
2096 self
2097 }
2098
2099 pub fn openapi_info(
2101 mut self,
2102 title: impl Into<String>,
2103 version: impl Into<String>,
2104 description: Option<impl Into<String>>,
2105 ) -> Self {
2106 self.api_title = title.into();
2107 self.api_version = version.into();
2108 self.api_description = description.map(|d| d.into());
2109 self
2110 }
2111
2112 pub fn body_limit(mut self, limit: usize) -> Self {
2114 self.body_limit = Some(limit);
2115 self
2116 }
2117
2118 pub fn layer<L>(mut self, layer: L) -> Self
2120 where
2121 L: MiddlewareLayer,
2122 {
2123 self.layers.push(Box::new(layer));
2124 self
2125 }
2126
2127 pub fn build(self) -> RustApi {
2129 let mut app = RustApi::new().mount_auto_routes_grouped();
2130
2131 if let Some(limit) = self.body_limit {
2133 app = app.body_limit(limit);
2134 }
2135
2136 app = app.openapi_info(
2137 &self.api_title,
2138 &self.api_version,
2139 self.api_description.as_deref(),
2140 );
2141
2142 #[cfg(feature = "swagger-ui")]
2143 if self.docs_enabled {
2144 if let Some(path) = self.docs_path {
2145 app = app.docs(&path);
2146 }
2147 }
2148
2149 app.layers.extend(self.layers);
2152
2153 app
2154 }
2155
2156 pub async fn run(
2158 self,
2159 addr: impl AsRef<str>,
2160 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2161 self.build().run(addr.as_ref()).await
2162 }
2163}
2164
2165#[cfg(test)]
2166mod tests {
2167 use super::RustApi;
2168 use crate::extract::{FromRequestParts, State};
2169 use crate::path_params::PathParams;
2170 use crate::request::Request;
2171 use crate::router::{get, post, Router};
2172 use bytes::Bytes;
2173 use http::Method;
2174 use proptest::prelude::*;
2175
2176 #[test]
2177 fn state_is_available_via_extractor() {
2178 let app = RustApi::new().state(123u32);
2179 let router = app.into_router();
2180
2181 let req = http::Request::builder()
2182 .method(Method::GET)
2183 .uri("/test")
2184 .body(())
2185 .unwrap();
2186 let (parts, _) = req.into_parts();
2187
2188 let request = Request::new(
2189 parts,
2190 crate::request::BodyVariant::Buffered(Bytes::new()),
2191 router.state_ref(),
2192 PathParams::new(),
2193 );
2194 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
2195 assert_eq!(value, 123u32);
2196 }
2197
2198 #[test]
2199 fn test_path_param_type_inference_integer() {
2200 use super::infer_path_param_schema;
2201
2202 let int_params = [
2204 "page",
2205 "limit",
2206 "offset",
2207 "count",
2208 "item_count",
2209 "year",
2210 "month",
2211 "day",
2212 "index",
2213 "position",
2214 ];
2215
2216 for name in int_params {
2217 let schema = infer_path_param_schema(name);
2218 match schema {
2219 rustapi_openapi::SchemaRef::Inline(v) => {
2220 assert_eq!(
2221 v.get("type").and_then(|v| v.as_str()),
2222 Some("integer"),
2223 "Expected '{}' to be inferred as integer",
2224 name
2225 );
2226 }
2227 _ => panic!("Expected inline schema for '{}'", name),
2228 }
2229 }
2230 }
2231
2232 #[test]
2233 fn test_path_param_type_inference_uuid() {
2234 use super::infer_path_param_schema;
2235
2236 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
2238
2239 for name in uuid_params {
2240 let schema = infer_path_param_schema(name);
2241 match schema {
2242 rustapi_openapi::SchemaRef::Inline(v) => {
2243 assert_eq!(
2244 v.get("type").and_then(|v| v.as_str()),
2245 Some("string"),
2246 "Expected '{}' to be inferred as string",
2247 name
2248 );
2249 assert_eq!(
2250 v.get("format").and_then(|v| v.as_str()),
2251 Some("uuid"),
2252 "Expected '{}' to have uuid format",
2253 name
2254 );
2255 }
2256 _ => panic!("Expected inline schema for '{}'", name),
2257 }
2258 }
2259 }
2260
2261 #[test]
2262 fn test_path_param_type_inference_string() {
2263 use super::infer_path_param_schema;
2264
2265 let string_params = [
2267 "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId",
2268 ];
2269
2270 for name in string_params {
2271 let schema = infer_path_param_schema(name);
2272 match schema {
2273 rustapi_openapi::SchemaRef::Inline(v) => {
2274 assert_eq!(
2275 v.get("type").and_then(|v| v.as_str()),
2276 Some("string"),
2277 "Expected '{}' to be inferred as string",
2278 name
2279 );
2280 assert!(
2281 v.get("format").is_none()
2282 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
2283 "Expected '{}' to NOT have uuid format",
2284 name
2285 );
2286 }
2287 _ => panic!("Expected inline schema for '{}'", name),
2288 }
2289 }
2290 }
2291
2292 #[test]
2293 fn test_schema_type_to_openapi_schema() {
2294 use super::schema_type_to_openapi_schema;
2295
2296 let uuid_schema = schema_type_to_openapi_schema("uuid");
2298 match uuid_schema {
2299 rustapi_openapi::SchemaRef::Inline(v) => {
2300 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2301 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid"));
2302 }
2303 _ => panic!("Expected inline schema for uuid"),
2304 }
2305
2306 for schema_type in ["integer", "int", "int64", "i64"] {
2308 let schema = schema_type_to_openapi_schema(schema_type);
2309 match schema {
2310 rustapi_openapi::SchemaRef::Inline(v) => {
2311 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
2312 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64"));
2313 }
2314 _ => panic!("Expected inline schema for {}", schema_type),
2315 }
2316 }
2317
2318 let int32_schema = schema_type_to_openapi_schema("int32");
2320 match int32_schema {
2321 rustapi_openapi::SchemaRef::Inline(v) => {
2322 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
2323 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32"));
2324 }
2325 _ => panic!("Expected inline schema for int32"),
2326 }
2327
2328 for schema_type in ["number", "float"] {
2330 let schema = schema_type_to_openapi_schema(schema_type);
2331 match schema {
2332 rustapi_openapi::SchemaRef::Inline(v) => {
2333 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number"));
2334 }
2335 _ => panic!("Expected inline schema for {}", schema_type),
2336 }
2337 }
2338
2339 for schema_type in ["boolean", "bool"] {
2341 let schema = schema_type_to_openapi_schema(schema_type);
2342 match schema {
2343 rustapi_openapi::SchemaRef::Inline(v) => {
2344 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean"));
2345 }
2346 _ => panic!("Expected inline schema for {}", schema_type),
2347 }
2348 }
2349
2350 let string_schema = schema_type_to_openapi_schema("string");
2352 match string_schema {
2353 rustapi_openapi::SchemaRef::Inline(v) => {
2354 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2355 }
2356 _ => panic!("Expected inline schema for string"),
2357 }
2358 }
2359
2360 proptest! {
2367 #![proptest_config(ProptestConfig::with_cases(100))]
2368
2369 #[test]
2374 fn prop_nested_routes_in_openapi_spec(
2375 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2377 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2379 has_param in any::<bool>(),
2380 ) {
2381 async fn handler() -> &'static str { "handler" }
2382
2383 let prefix = format!("/{}", prefix_segments.join("/"));
2385
2386 let mut route_path = format!("/{}", route_segments.join("/"));
2388 if has_param {
2389 route_path.push_str("/{id}");
2390 }
2391
2392 let nested_router = Router::new().route(&route_path, get(handler));
2394 let app = RustApi::new().nest(&prefix, nested_router);
2395
2396 let expected_openapi_path = format!("{}{}", prefix, route_path);
2398
2399 let spec = app.openapi_spec();
2401
2402 prop_assert!(
2404 spec.paths.contains_key(&expected_openapi_path),
2405 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2406 expected_openapi_path,
2407 spec.paths.keys().collect::<Vec<_>>()
2408 );
2409
2410 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2412 prop_assert!(
2413 path_item.get.is_some(),
2414 "GET operation should exist for path '{}'",
2415 expected_openapi_path
2416 );
2417 }
2418
2419 #[test]
2424 fn prop_multiple_methods_preserved_in_openapi(
2425 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2426 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2427 ) {
2428 async fn get_handler() -> &'static str { "get" }
2429 async fn post_handler() -> &'static str { "post" }
2430
2431 let prefix = format!("/{}", prefix_segments.join("/"));
2433 let route_path = format!("/{}", route_segments.join("/"));
2434
2435 let get_route_path = format!("{}/get", route_path);
2438 let post_route_path = format!("{}/post", route_path);
2439 let nested_router = Router::new()
2440 .route(&get_route_path, get(get_handler))
2441 .route(&post_route_path, post(post_handler));
2442 let app = RustApi::new().nest(&prefix, nested_router);
2443
2444 let expected_get_path = format!("{}{}", prefix, get_route_path);
2446 let expected_post_path = format!("{}{}", prefix, post_route_path);
2447
2448 let spec = app.openapi_spec();
2450
2451 prop_assert!(
2453 spec.paths.contains_key(&expected_get_path),
2454 "Expected OpenAPI path '{}' not found",
2455 expected_get_path
2456 );
2457 prop_assert!(
2458 spec.paths.contains_key(&expected_post_path),
2459 "Expected OpenAPI path '{}' not found",
2460 expected_post_path
2461 );
2462
2463 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
2465 prop_assert!(
2466 get_path_item.get.is_some(),
2467 "GET operation should exist for path '{}'",
2468 expected_get_path
2469 );
2470
2471 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
2473 prop_assert!(
2474 post_path_item.post.is_some(),
2475 "POST operation should exist for path '{}'",
2476 expected_post_path
2477 );
2478 }
2479
2480 #[test]
2485 fn prop_path_params_in_openapi_after_nesting(
2486 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2487 param_name in "[a-z][a-z0-9]{0,5}",
2488 ) {
2489 async fn handler() -> &'static str { "handler" }
2490
2491 let prefix = format!("/{}", prefix_segments.join("/"));
2493 let route_path = format!("/{{{}}}", param_name);
2494
2495 let nested_router = Router::new().route(&route_path, get(handler));
2497 let app = RustApi::new().nest(&prefix, nested_router);
2498
2499 let expected_openapi_path = format!("{}{}", prefix, route_path);
2501
2502 let spec = app.openapi_spec();
2504
2505 prop_assert!(
2507 spec.paths.contains_key(&expected_openapi_path),
2508 "Expected OpenAPI path '{}' not found",
2509 expected_openapi_path
2510 );
2511
2512 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2514 let get_op = path_item.get.as_ref().unwrap();
2515
2516 prop_assert!(
2517 !get_op.parameters.is_empty(),
2518 "Operation should have parameters for path '{}'",
2519 expected_openapi_path
2520 );
2521
2522 let params = &get_op.parameters;
2523 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
2524 prop_assert!(
2525 has_param,
2526 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
2527 param_name,
2528 params.iter().map(|p| &p.name).collect::<Vec<_>>()
2529 );
2530 }
2531 }
2532
2533 proptest! {
2541 #![proptest_config(ProptestConfig::with_cases(100))]
2542
2543 #[test]
2548 fn prop_rustapi_nest_delegates_to_router_nest(
2549 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2550 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2551 has_param in any::<bool>(),
2552 ) {
2553 async fn handler() -> &'static str { "handler" }
2554
2555 let prefix = format!("/{}", prefix_segments.join("/"));
2557
2558 let mut route_path = format!("/{}", route_segments.join("/"));
2560 if has_param {
2561 route_path.push_str("/{id}");
2562 }
2563
2564 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2566 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2567
2568 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2570 let rustapi_router = rustapi_app.into_router();
2571
2572 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2574
2575 let rustapi_routes = rustapi_router.registered_routes();
2577 let router_routes = router_app.registered_routes();
2578
2579 prop_assert_eq!(
2580 rustapi_routes.len(),
2581 router_routes.len(),
2582 "RustApi and Router should have same number of routes"
2583 );
2584
2585 for (path, info) in router_routes {
2587 prop_assert!(
2588 rustapi_routes.contains_key(path),
2589 "Route '{}' from Router should exist in RustApi routes",
2590 path
2591 );
2592
2593 let rustapi_info = rustapi_routes.get(path).unwrap();
2594 prop_assert_eq!(
2595 &info.path, &rustapi_info.path,
2596 "Display paths should match for route '{}'",
2597 path
2598 );
2599 prop_assert_eq!(
2600 info.methods.len(), rustapi_info.methods.len(),
2601 "Method count should match for route '{}'",
2602 path
2603 );
2604 }
2605 }
2606
2607 #[test]
2612 fn prop_rustapi_nest_includes_routes_in_openapi(
2613 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2614 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2615 has_param in any::<bool>(),
2616 ) {
2617 async fn handler() -> &'static str { "handler" }
2618
2619 let prefix = format!("/{}", prefix_segments.join("/"));
2621
2622 let mut route_path = format!("/{}", route_segments.join("/"));
2624 if has_param {
2625 route_path.push_str("/{id}");
2626 }
2627
2628 let nested_router = Router::new().route(&route_path, get(handler));
2630 let app = RustApi::new().nest(&prefix, nested_router);
2631
2632 let expected_openapi_path = format!("{}{}", prefix, route_path);
2634
2635 let spec = app.openapi_spec();
2637
2638 prop_assert!(
2640 spec.paths.contains_key(&expected_openapi_path),
2641 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2642 expected_openapi_path,
2643 spec.paths.keys().collect::<Vec<_>>()
2644 );
2645
2646 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2648 prop_assert!(
2649 path_item.get.is_some(),
2650 "GET operation should exist for path '{}'",
2651 expected_openapi_path
2652 );
2653 }
2654
2655 #[test]
2660 fn prop_rustapi_nest_route_matching_identical(
2661 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2662 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2663 param_value in "[a-z0-9]{1,10}",
2664 ) {
2665 use crate::router::RouteMatch;
2666
2667 async fn handler() -> &'static str { "handler" }
2668
2669 let prefix = format!("/{}", prefix_segments.join("/"));
2671 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
2672
2673 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2675 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2676
2677 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2679 let rustapi_router = rustapi_app.into_router();
2680 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2681
2682 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
2684
2685 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
2687 let router_match = router_app.match_route(&full_path, &Method::GET);
2688
2689 match (rustapi_match, router_match) {
2691 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
2692 prop_assert_eq!(
2693 rustapi_params.len(),
2694 router_params.len(),
2695 "Parameter count should match"
2696 );
2697 for (key, value) in &router_params {
2698 prop_assert!(
2699 rustapi_params.contains_key(key),
2700 "RustApi should have parameter '{}'",
2701 key
2702 );
2703 prop_assert_eq!(
2704 rustapi_params.get(key).unwrap(),
2705 value,
2706 "Parameter '{}' value should match",
2707 key
2708 );
2709 }
2710 }
2711 (rustapi_result, router_result) => {
2712 prop_assert!(
2713 false,
2714 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
2715 match rustapi_result {
2716 RouteMatch::Found { .. } => "Found",
2717 RouteMatch::NotFound => "NotFound",
2718 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2719 },
2720 match router_result {
2721 RouteMatch::Found { .. } => "Found",
2722 RouteMatch::NotFound => "NotFound",
2723 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2724 }
2725 );
2726 }
2727 }
2728 }
2729 }
2730
2731 #[test]
2733 fn test_openapi_operations_propagated_during_nesting() {
2734 async fn list_users() -> &'static str {
2735 "list users"
2736 }
2737 async fn get_user() -> &'static str {
2738 "get user"
2739 }
2740 async fn create_user() -> &'static str {
2741 "create user"
2742 }
2743
2744 let users_router = Router::new()
2747 .route("/", get(list_users))
2748 .route("/create", post(create_user))
2749 .route("/{id}", get(get_user));
2750
2751 let app = RustApi::new().nest("/api/v1/users", users_router);
2753
2754 let spec = app.openapi_spec();
2755
2756 assert!(
2758 spec.paths.contains_key("/api/v1/users"),
2759 "Should have /api/v1/users path"
2760 );
2761 let users_path = spec.paths.get("/api/v1/users").unwrap();
2762 assert!(users_path.get.is_some(), "Should have GET operation");
2763
2764 assert!(
2766 spec.paths.contains_key("/api/v1/users/create"),
2767 "Should have /api/v1/users/create path"
2768 );
2769 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
2770 assert!(create_path.post.is_some(), "Should have POST operation");
2771
2772 assert!(
2774 spec.paths.contains_key("/api/v1/users/{id}"),
2775 "Should have /api/v1/users/{{id}} path"
2776 );
2777 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
2778 assert!(
2779 user_path.get.is_some(),
2780 "Should have GET operation for user by id"
2781 );
2782
2783 let get_user_op = user_path.get.as_ref().unwrap();
2785 assert!(!get_user_op.parameters.is_empty(), "Should have parameters");
2786 let params = &get_user_op.parameters;
2787 assert!(
2788 params
2789 .iter()
2790 .any(|p| p.name == "id" && p.location == "path"),
2791 "Should have 'id' path parameter"
2792 );
2793 }
2794
2795 #[test]
2797 fn test_openapi_spec_empty_without_routes() {
2798 let app = RustApi::new();
2799 let spec = app.openapi_spec();
2800
2801 assert!(
2803 spec.paths.is_empty(),
2804 "OpenAPI spec should have no paths without routes"
2805 );
2806 }
2807
2808 #[test]
2813 fn test_rustapi_nest_delegates_to_router_nest() {
2814 use crate::router::RouteMatch;
2815
2816 async fn list_users() -> &'static str {
2817 "list users"
2818 }
2819 async fn get_user() -> &'static str {
2820 "get user"
2821 }
2822 async fn create_user() -> &'static str {
2823 "create user"
2824 }
2825
2826 let users_router = Router::new()
2828 .route("/", get(list_users))
2829 .route("/create", post(create_user))
2830 .route("/{id}", get(get_user));
2831
2832 let app = RustApi::new().nest("/api/v1/users", users_router);
2834 let router = app.into_router();
2835
2836 let routes = router.registered_routes();
2838 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
2839
2840 assert!(
2842 routes.contains_key("/api/v1/users"),
2843 "Should have /api/v1/users route"
2844 );
2845 assert!(
2846 routes.contains_key("/api/v1/users/create"),
2847 "Should have /api/v1/users/create route"
2848 );
2849 assert!(
2850 routes.contains_key("/api/v1/users/:id"),
2851 "Should have /api/v1/users/:id route"
2852 );
2853
2854 match router.match_route("/api/v1/users", &Method::GET) {
2856 RouteMatch::Found { params, .. } => {
2857 assert!(params.is_empty(), "Root route should have no params");
2858 }
2859 _ => panic!("GET /api/v1/users should be found"),
2860 }
2861
2862 match router.match_route("/api/v1/users/create", &Method::POST) {
2863 RouteMatch::Found { params, .. } => {
2864 assert!(params.is_empty(), "Create route should have no params");
2865 }
2866 _ => panic!("POST /api/v1/users/create should be found"),
2867 }
2868
2869 match router.match_route("/api/v1/users/123", &Method::GET) {
2870 RouteMatch::Found { params, .. } => {
2871 assert_eq!(
2872 params.get("id"),
2873 Some(&"123".to_string()),
2874 "Should extract id param"
2875 );
2876 }
2877 _ => panic!("GET /api/v1/users/123 should be found"),
2878 }
2879
2880 match router.match_route("/api/v1/users", &Method::DELETE) {
2882 RouteMatch::MethodNotAllowed { allowed } => {
2883 assert!(allowed.contains(&Method::GET), "Should allow GET");
2884 }
2885 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
2886 }
2887 }
2888
2889 #[test]
2894 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
2895 async fn list_items() -> &'static str {
2896 "list items"
2897 }
2898 async fn get_item() -> &'static str {
2899 "get item"
2900 }
2901
2902 let items_router = Router::new()
2904 .route("/", get(list_items))
2905 .route("/{item_id}", get(get_item));
2906
2907 let app = RustApi::new().nest("/api/items", items_router);
2909
2910 let spec = app.openapi_spec();
2912
2913 assert!(
2915 spec.paths.contains_key("/api/items"),
2916 "Should have /api/items in OpenAPI"
2917 );
2918 assert!(
2919 spec.paths.contains_key("/api/items/{item_id}"),
2920 "Should have /api/items/{{item_id}} in OpenAPI"
2921 );
2922
2923 let list_path = spec.paths.get("/api/items").unwrap();
2925 assert!(
2926 list_path.get.is_some(),
2927 "Should have GET operation for /api/items"
2928 );
2929
2930 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
2931 assert!(
2932 get_path.get.is_some(),
2933 "Should have GET operation for /api/items/{{item_id}}"
2934 );
2935
2936 let get_op = get_path.get.as_ref().unwrap();
2938 assert!(!get_op.parameters.is_empty(), "Should have parameters");
2939 let params = &get_op.parameters;
2940 assert!(
2941 params
2942 .iter()
2943 .any(|p| p.name == "item_id" && p.location == "path"),
2944 "Should have 'item_id' path parameter"
2945 );
2946 }
2947}