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 maybe_dump_openapi(&self) {
554 if let Ok(val) = std::env::var("RUSTAPI_DUMP_OPENAPI") {
555 if matches!(val.as_str(), "1" | "true" | "yes") {
556 let json = self.openapi_spec.to_json();
557 if let Ok(pretty) = serde_json::to_string_pretty(&json) {
559 println!("{}", pretty);
560 } else {
561 println!("{}", json);
562 }
563 std::process::exit(0);
564 }
565 }
566 }
567
568 fn mount_auto_routes_grouped(mut self) -> Self {
569 let routes = crate::auto_route::collect_auto_routes();
570
571 if routes.is_empty() {
572 tracing::warn!(
575 target: "rustapi::auto",
576 count = 0,
577 "RustApi::auto() collected 0 routes. \
578 This usually means either:\n\
579 - No handlers were annotated with #[rustapi_rs::get], #[post], etc.\n\
580 - The binary/test was not linked with the annotated modules (common in some test setups).\n\
581 - You are building a library (cdylib/rlib) where linkme distributed slices may not be populated.\n\n\
582 You can still register routes manually with .route() or check with rustapi_rs::auto_route_count()."
583 );
584 } else {
585 #[cfg(feature = "tracing")]
586 tracing::debug!(
587 target: "rustapi::auto",
588 count = routes.len(),
589 "Auto route collection found handlers"
590 );
591 }
592
593 let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();
595
596 for route in routes {
597 let crate::handler::Route {
598 path: route_path,
599 method,
600 handler,
601 operation,
602 component_registrar,
603 ..
604 } = route;
605
606 let method_enum = match method {
607 "GET" => http::Method::GET,
608 "POST" => http::Method::POST,
609 "PUT" => http::Method::PUT,
610 "DELETE" => http::Method::DELETE,
611 "PATCH" => http::Method::PATCH,
612 _ => http::Method::GET,
613 };
614
615 let path = if route_path.starts_with('/') {
616 route_path.to_string()
617 } else {
618 format!("/{}", route_path)
619 };
620
621 let entry = by_path.entry(path).or_default();
622 entry.insert_boxed_with_operation(method_enum, handler, operation, component_registrar);
623 }
624
625 #[cfg(feature = "tracing")]
626 let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
627 #[cfg(feature = "tracing")]
628 let path_count = by_path.len();
629
630 for (path, method_router) in by_path {
631 self = self.route(&path, method_router);
632 }
633
634 crate::trace_info!(
635 paths = path_count,
636 routes = route_count,
637 "Auto-registered routes"
638 );
639
640 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
642
643 self
644 }
645
646 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
657 for register_components in &method_router.component_registrars {
658 register_components(&mut self.openapi_spec);
659 }
660
661 for (method, op) in &method_router.operations {
663 let mut op = op.clone();
664 add_path_params_to_operation(path, &mut op, &BTreeMap::new());
665 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
666 }
667
668 self.router = self.router.route(path, method_router);
669 self
670 }
671
672 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
674 self.route(P::PATH, method_router)
675 }
676
677 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
681 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
682 self.route(path, method_router)
683 }
684
685 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
703 let method_enum = match route.method {
704 "GET" => http::Method::GET,
705 "POST" => http::Method::POST,
706 "PUT" => http::Method::PUT,
707 "DELETE" => http::Method::DELETE,
708 "PATCH" => http::Method::PATCH,
709 _ => http::Method::GET,
710 };
711
712 (route.component_registrar)(&mut self.openapi_spec);
713
714 let mut op = route.operation;
716 add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
717 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
718
719 self.route_with_method(route.path, method_enum, route.handler)
720 }
721
722 fn route_with_method(
724 self,
725 path: &str,
726 method: http::Method,
727 handler: crate::handler::BoxedHandler,
728 ) -> Self {
729 use crate::router::MethodRouter;
730 let path = if !path.starts_with('/') {
739 format!("/{}", path)
740 } else {
741 path.to_string()
742 };
743
744 let mut handlers = std::collections::HashMap::new();
753 handlers.insert(method, handler);
754
755 let method_router = MethodRouter::from_boxed(handlers);
756 self.route(&path, method_router)
757 }
758
759 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
775 let normalized_prefix = normalize_prefix_for_openapi(prefix);
777
778 for (matchit_path, method_router) in router.method_routers() {
781 for register_components in &method_router.component_registrars {
782 register_components(&mut self.openapi_spec);
783 }
784
785 let display_path = router
787 .registered_routes()
788 .get(matchit_path)
789 .map(|info| info.path.clone())
790 .unwrap_or_else(|| matchit_path.clone());
791
792 let prefixed_path = if display_path == "/" {
794 normalized_prefix.clone()
795 } else {
796 format!("{}{}", normalized_prefix, display_path)
797 };
798
799 for (method, op) in &method_router.operations {
801 let mut op = op.clone();
802 add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
803 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
804 }
805 }
806
807 self.router = self.router.nest(prefix, router);
809 self
810 }
811
812 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
841 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
842 }
843
844 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
861 use crate::router::MethodRouter;
862 use std::collections::HashMap;
863
864 let prefix = config.prefix.clone();
865 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
866
867 let handler: crate::handler::BoxedHandler =
869 std::sync::Arc::new(move |req: crate::Request| {
870 let config = config.clone();
871 let path = req.uri().path().to_string();
872
873 Box::pin(async move {
874 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
875
876 match crate::static_files::StaticFile::serve(relative_path, &config).await {
877 Ok(response) => response,
878 Err(err) => err.into_response(),
879 }
880 })
881 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
882 });
883
884 let mut handlers = HashMap::new();
885 handlers.insert(http::Method::GET, handler);
886 let method_router = MethodRouter::from_boxed(handlers);
887
888 self.route(&catch_all_path, method_router)
889 }
890
891 #[cfg(feature = "compression")]
908 pub fn compression(self) -> Self {
909 self.layer(crate::middleware::CompressionLayer::new())
910 }
911
912 #[cfg(feature = "compression")]
928 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
929 self.layer(crate::middleware::CompressionLayer::with_config(config))
930 }
931
932 #[cfg(feature = "swagger-ui")]
956 pub fn docs(self, path: &str) -> Self {
957 let title = self.openapi_spec.info.title.clone();
958 let version = self.openapi_spec.info.version.clone();
959 let description = self.openapi_spec.info.description.clone();
960
961 self.docs_with_info(path, &title, &version, description.as_deref())
962 }
963
964 #[cfg(feature = "swagger-ui")]
973 pub fn docs_with_info(
974 mut self,
975 path: &str,
976 title: &str,
977 version: &str,
978 description: Option<&str>,
979 ) -> Self {
980 use crate::router::get;
981 self.openapi_spec.info.title = title.to_string();
983 self.openapi_spec.info.version = version.to_string();
984 if let Some(desc) = description {
985 self.openapi_spec.info.description = Some(desc.to_string());
986 }
987
988 let path = path.trim_end_matches('/');
989 let openapi_path = format!("{}/openapi.json", path);
990
991 let spec_value = self.openapi_spec.to_json();
993 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
994 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
996 "{}".to_string()
997 });
998 let openapi_url = openapi_path.clone();
999
1000 let spec_handler = move || {
1002 let json = spec_json.clone();
1003 async move {
1004 http::Response::builder()
1005 .status(http::StatusCode::OK)
1006 .header(http::header::CONTENT_TYPE, "application/json")
1007 .body(crate::response::Body::from(json))
1008 .unwrap_or_else(|e| {
1009 tracing::error!("Failed to build response: {}", e);
1010 http::Response::builder()
1011 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1012 .body(crate::response::Body::from("Internal Server Error"))
1013 .unwrap()
1014 })
1015 }
1016 };
1017
1018 let docs_handler = move || {
1020 let url = openapi_url.clone();
1021 async move {
1022 let response = rustapi_openapi::swagger_ui_html(&url);
1023 response.map(crate::response::Body::Full)
1024 }
1025 };
1026
1027 self.route(&openapi_path, get(spec_handler))
1028 .route(path, get(docs_handler))
1029 }
1030
1031 #[cfg(feature = "swagger-ui")]
1047 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
1048 let title = self.openapi_spec.info.title.clone();
1049 let version = self.openapi_spec.info.version.clone();
1050 let description = self.openapi_spec.info.description.clone();
1051
1052 self.docs_with_auth_and_info(
1053 path,
1054 username,
1055 password,
1056 &title,
1057 &version,
1058 description.as_deref(),
1059 )
1060 }
1061
1062 #[cfg(feature = "swagger-ui")]
1078 pub fn docs_with_auth_and_info(
1079 mut self,
1080 path: &str,
1081 username: &str,
1082 password: &str,
1083 title: &str,
1084 version: &str,
1085 description: Option<&str>,
1086 ) -> Self {
1087 use crate::router::MethodRouter;
1088 use std::collections::HashMap;
1089
1090 #[inline]
1091 fn base64_encode(input: &[u8]) -> String {
1092 const ALPHA: &[u8; 64] =
1093 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1094 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1095 for chunk in input.chunks(3) {
1096 let b0 = chunk[0] as usize;
1097 let b1 = if chunk.len() > 1 {
1098 chunk[1] as usize
1099 } else {
1100 0
1101 };
1102 let b2 = if chunk.len() > 2 {
1103 chunk[2] as usize
1104 } else {
1105 0
1106 };
1107 out.push(ALPHA[b0 >> 2] as char);
1108 out.push(ALPHA[((b0 & 3) << 4) | (b1 >> 4)] as char);
1109 out.push(if chunk.len() > 1 {
1110 ALPHA[((b1 & 0xf) << 2) | (b2 >> 6)] as char
1111 } else {
1112 '='
1113 });
1114 out.push(if chunk.len() > 2 {
1115 ALPHA[b2 & 63] as char
1116 } else {
1117 '='
1118 });
1119 }
1120 out
1121 }
1122
1123 self.openapi_spec.info.title = title.to_string();
1125 self.openapi_spec.info.version = version.to_string();
1126 if let Some(desc) = description {
1127 self.openapi_spec.info.description = Some(desc.to_string());
1128 }
1129
1130 let path = path.trim_end_matches('/');
1131 let openapi_path = format!("{}/openapi.json", path);
1132
1133 let credentials = format!("{}:{}", username, password);
1135 let encoded = base64_encode(credentials.as_bytes());
1136 let expected_auth = format!("Basic {}", encoded);
1137
1138 let spec_value = self.openapi_spec.to_json();
1140 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
1141 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
1142 "{}".to_string()
1143 });
1144 let openapi_url = openapi_path.clone();
1145 let expected_auth_spec = expected_auth.clone();
1146 let expected_auth_docs = expected_auth;
1147
1148 let spec_handler: crate::handler::BoxedHandler =
1150 std::sync::Arc::new(move |req: crate::Request| {
1151 let json = spec_json.clone();
1152 let expected = expected_auth_spec.clone();
1153 Box::pin(async move {
1154 if !check_basic_auth(&req, &expected) {
1155 return unauthorized_response();
1156 }
1157 http::Response::builder()
1158 .status(http::StatusCode::OK)
1159 .header(http::header::CONTENT_TYPE, "application/json")
1160 .body(crate::response::Body::from(json))
1161 .unwrap_or_else(|e| {
1162 tracing::error!("Failed to build response: {}", e);
1163 http::Response::builder()
1164 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1165 .body(crate::response::Body::from("Internal Server Error"))
1166 .unwrap()
1167 })
1168 })
1169 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1170 });
1171
1172 let docs_handler: crate::handler::BoxedHandler =
1174 std::sync::Arc::new(move |req: crate::Request| {
1175 let url = openapi_url.clone();
1176 let expected = expected_auth_docs.clone();
1177 Box::pin(async move {
1178 if !check_basic_auth(&req, &expected) {
1179 return unauthorized_response();
1180 }
1181 let response = rustapi_openapi::swagger_ui_html(&url);
1182 response.map(crate::response::Body::Full)
1183 })
1184 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1185 });
1186
1187 let mut spec_handlers = HashMap::new();
1189 spec_handlers.insert(http::Method::GET, spec_handler);
1190 let spec_router = MethodRouter::from_boxed(spec_handlers);
1191
1192 let mut docs_handlers = HashMap::new();
1193 docs_handlers.insert(http::Method::GET, docs_handler);
1194 let docs_router = MethodRouter::from_boxed(docs_handlers);
1195
1196 self.route(&openapi_path, spec_router)
1197 .route(path, docs_router)
1198 }
1199
1200 pub fn status_page(self) -> Self {
1202 self.status_page_with_config(crate::status::StatusConfig::default())
1203 }
1204
1205 pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self {
1207 self.status_config = Some(config);
1208 self
1209 }
1210
1211 pub fn health_endpoints(mut self) -> Self {
1216 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1217 if self.health_check.is_none() {
1218 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1219 }
1220 self
1221 }
1222
1223 pub fn health_endpoints_with_config(
1225 mut self,
1226 config: crate::health::HealthEndpointConfig,
1227 ) -> Self {
1228 self.health_endpoint_config = Some(config);
1229 if self.health_check.is_none() {
1230 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1231 }
1232 self
1233 }
1234
1235 pub fn with_health_check(mut self, health_check: crate::health::HealthCheck) -> Self {
1240 self.health_check = Some(health_check);
1241 if self.health_endpoint_config.is_none() {
1242 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1243 }
1244 self
1245 }
1246
1247 pub fn production_defaults(self, service_name: impl Into<String>) -> Self {
1254 self.production_defaults_with_config(ProductionDefaultsConfig::new(service_name))
1255 }
1256
1257 pub fn production_defaults_with_config(mut self, config: ProductionDefaultsConfig) -> Self {
1259 if config.enable_request_id {
1260 self = self.layer(crate::middleware::RequestIdLayer::new());
1261 }
1262
1263 if config.enable_tracing {
1264 let mut tracing_layer =
1265 crate::middleware::TracingLayer::with_level(config.tracing_level)
1266 .with_field("service", config.service_name.clone())
1267 .with_field("environment", crate::error::get_environment().to_string());
1268
1269 if let Some(version) = &config.version {
1270 tracing_layer = tracing_layer.with_field("version", version.clone());
1271 }
1272
1273 self = self.layer(tracing_layer);
1274 }
1275
1276 if config.enable_health_endpoints {
1277 if self.health_check.is_none() {
1278 let mut builder = crate::health::HealthCheckBuilder::default();
1279 if let Some(version) = &config.version {
1280 builder = builder.version(version.clone());
1281 }
1282 self.health_check = Some(builder.build());
1283 }
1284
1285 if self.health_endpoint_config.is_none() {
1286 self.health_endpoint_config =
1287 Some(config.health_endpoint_config.unwrap_or_default());
1288 }
1289 }
1290
1291 self
1292 }
1293
1294 fn print_hot_reload_banner(&self, addr: &str) {
1296 if !self.hot_reload {
1297 return;
1298 }
1299
1300 std::env::set_var("RUSTAPI_HOT_RELOAD", "1");
1302
1303 let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD")
1304 .map(|v| v == "1")
1305 .unwrap_or(false);
1306
1307 tracing::info!("🔄 Hot-reload mode enabled");
1308
1309 if is_under_watcher {
1310 tracing::info!(" File watcher active — changes will trigger rebuild + restart");
1311 } else {
1312 tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload");
1313 }
1314
1315 tracing::info!(" Listening on http://{addr}");
1316 }
1317
1318 fn apply_health_endpoints(&mut self) {
1320 if let Some(config) = &self.health_endpoint_config {
1321 use crate::router::get;
1322
1323 let health_check = self
1324 .health_check
1325 .clone()
1326 .unwrap_or_else(|| crate::health::HealthCheckBuilder::default().build());
1327
1328 let health_path = config.health_path.clone();
1329 let readiness_path = config.readiness_path.clone();
1330 let liveness_path = config.liveness_path.clone();
1331
1332 let health_handler = {
1333 let health_check = health_check.clone();
1334 move || {
1335 let health_check = health_check.clone();
1336 async move { crate::health::health_response(health_check).await }
1337 }
1338 };
1339
1340 let readiness_handler = {
1341 let health_check = health_check.clone();
1342 move || {
1343 let health_check = health_check.clone();
1344 async move { crate::health::readiness_response(health_check).await }
1345 }
1346 };
1347
1348 let liveness_handler = || async { crate::health::liveness_response().await };
1349
1350 let router = std::mem::take(&mut self.router);
1351 self.router = router
1352 .route(&health_path, get(health_handler))
1353 .route(&readiness_path, get(readiness_handler))
1354 .route(&liveness_path, get(liveness_handler));
1355 }
1356 }
1357
1358 fn apply_status_page(&mut self) {
1359 if let Some(config) = &self.status_config {
1360 let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new());
1361
1362 self.layers
1364 .push(Box::new(crate::status::StatusLayer::new(monitor.clone())));
1365
1366 use crate::router::MethodRouter;
1368 use std::collections::HashMap;
1369
1370 let monitor = monitor.clone();
1371 let config = config.clone();
1372 let path = config.path.clone(); let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| {
1375 let monitor = monitor.clone();
1376 let config = config.clone();
1377 Box::pin(async move {
1378 crate::status::status_handler(monitor, config)
1379 .await
1380 .into_response()
1381 })
1382 });
1383
1384 let mut handlers = HashMap::new();
1385 handlers.insert(http::Method::GET, handler);
1386 let method_router = MethodRouter::from_boxed(handlers);
1387
1388 let router = std::mem::take(&mut self.router);
1390 self.router = router.route(&path, method_router);
1391 }
1392 }
1393
1394 #[cfg(feature = "dashboard")]
1395 fn apply_dashboard(&mut self) {
1396 use crate::dashboard::{DashboardMetrics, RouteInventoryItem};
1397 use crate::handler::BoxedHandler;
1398 use crate::response::Body;
1399 use crate::router::MethodRouter;
1400 use std::collections::HashMap;
1401 use std::sync::Arc;
1402
1403 let mut config = match self.dashboard_config.take() {
1404 Some(c) => c,
1405 None => return,
1406 };
1407 config.normalize_paths();
1408
1409 let mut inventory: Vec<RouteInventoryItem> = self
1413 .router
1414 .registered_routes()
1415 .values()
1416 .map(|info| {
1417 let methods: Vec<String> = info.methods.iter().map(|m| m.to_string()).collect();
1418 let health_eligible = self
1419 .health_endpoint_config
1420 .as_ref()
1421 .map(|health| {
1422 info.path == health.health_path
1423 || info.path == health.readiness_path
1424 || info.path == health.liveness_path
1425 })
1426 .unwrap_or(false);
1427
1428 RouteInventoryItem::new(info.path.clone(), methods)
1429 .with_tags(openapi_tags_for_route(
1430 &self.openapi_spec,
1431 &info.path,
1432 &info.methods,
1433 ))
1434 .with_feature_gates(infer_route_feature_gates(&info.path))
1435 .health_eligible(health_eligible)
1436 .replay_eligible(is_dashboard_replay_eligible(&info.path, health_eligible))
1437 })
1438 .collect();
1439 inventory.sort_by(|a, b| a.path.cmp(&b.path));
1440
1441 let metrics = Arc::new(DashboardMetrics::new_with_replay_admin_path(
1442 inventory,
1443 config.replay_api_path.clone(),
1444 ));
1445
1446 let router = std::mem::take(&mut self.router);
1448 self.router = router.state(Arc::clone(&metrics));
1449
1450 let prefix = config.path.trim_end_matches('/').to_owned();
1452
1453 fn not_found() -> crate::response::Response {
1454 http::Response::builder()
1455 .status(404)
1456 .body(Body::Full(http_body_util::Full::new(bytes::Bytes::from(
1457 "Not Found",
1458 ))))
1459 .unwrap()
1460 }
1461
1462 {
1464 let metrics_c = Arc::clone(&metrics);
1465 let config_c = config.clone();
1466 let handler: BoxedHandler = Arc::new(move |req| {
1467 let metrics = Arc::clone(&metrics_c);
1468 let cfg = config_c.clone();
1469 Box::pin(async move {
1470 let headers = req.headers().clone();
1471 let method = req.method().to_string();
1472 let path = req.uri().path().to_owned();
1473 crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg)
1474 .await
1475 .unwrap_or_else(not_found)
1476 })
1477 });
1478 let mut h = HashMap::new();
1479 h.insert(http::Method::GET, handler);
1480 let router = std::mem::take(&mut self.router);
1481 self.router = router.route(&prefix, MethodRouter::from_boxed(h));
1482 }
1483
1484 {
1486 let metrics_c = Arc::clone(&metrics);
1487 let config_c = config.clone();
1488 let wildcard_path = format!("{}/*path", prefix);
1489 let handler: BoxedHandler = Arc::new(move |req| {
1490 let metrics = Arc::clone(&metrics_c);
1491 let cfg = config_c.clone();
1492 Box::pin(async move {
1493 let headers = req.headers().clone();
1494 let method = req.method().to_string();
1495 let path = req.uri().path().to_owned();
1496 crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg)
1497 .await
1498 .unwrap_or_else(not_found)
1499 })
1500 });
1501 let mut h = HashMap::new();
1502 h.insert(http::Method::GET, handler);
1503 let router = std::mem::take(&mut self.router);
1504 self.router = router.route(&wildcard_path, MethodRouter::from_boxed(h));
1505 }
1506 }
1507
1508 #[cfg(feature = "dashboard")]
1528 pub fn dashboard(mut self, config: crate::dashboard::DashboardConfig) -> Self {
1529 self.dashboard_config = Some(config);
1530 self
1531 }
1532
1533 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1544 self.maybe_dump_openapi();
1545
1546 self.print_hot_reload_banner(addr);
1548
1549 self.apply_health_endpoints();
1551
1552 self.apply_status_page();
1554
1555 #[cfg(feature = "dashboard")]
1557 self.apply_dashboard();
1558
1559 if let Some(limit) = self.body_limit {
1561 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1563 }
1564
1565 for hook in self.lifecycle_hooks.on_start {
1567 hook().await;
1568 }
1569
1570 let server = Server::new(self.router, self.layers, self.interceptors);
1571 server.run(addr).await
1572 }
1573
1574 pub async fn run_with_shutdown<F>(
1576 mut self,
1577 addr: impl AsRef<str>,
1578 signal: F,
1579 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1580 where
1581 F: std::future::Future<Output = ()> + Send + 'static,
1582 {
1583 self.maybe_dump_openapi();
1584
1585 self.print_hot_reload_banner(addr.as_ref());
1587
1588 self.apply_health_endpoints();
1590
1591 self.apply_status_page();
1593
1594 #[cfg(feature = "dashboard")]
1596 self.apply_dashboard();
1597
1598 if let Some(limit) = self.body_limit {
1599 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1600 }
1601
1602 for hook in self.lifecycle_hooks.on_start {
1604 hook().await;
1605 }
1606
1607 let shutdown_hooks = self.lifecycle_hooks.on_shutdown;
1609 let wrapped_signal = async move {
1610 signal.await;
1611 for hook in shutdown_hooks {
1613 hook().await;
1614 }
1615 };
1616
1617 let server = Server::new(self.router, self.layers, self.interceptors);
1618 server
1619 .run_with_shutdown(addr.as_ref(), wrapped_signal)
1620 .await
1621 }
1622
1623 pub fn into_router(self) -> Router {
1625 self.router
1626 }
1627
1628 pub fn router(&self) -> &Router {
1630 &self.router
1631 }
1632
1633 pub fn layers(&self) -> &LayerStack {
1635 &self.layers
1636 }
1637
1638 pub fn interceptors(&self) -> &InterceptorChain {
1640 &self.interceptors
1641 }
1642
1643 pub fn request_dispatcher(&self) -> RequestDispatcher {
1649 RequestDispatcher {
1650 router: Arc::new(self.router.clone()),
1651 layers: self.layers().clone(),
1652 interceptors: self.interceptors().clone(),
1653 }
1654 }
1655
1656 #[cfg(feature = "http3")]
1670 pub async fn run_http3(
1671 mut self,
1672 config: crate::http3::Http3Config,
1673 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1674 use std::sync::Arc;
1675
1676 self.apply_health_endpoints();
1678
1679 self.apply_status_page();
1681
1682 if let Some(limit) = self.body_limit {
1684 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1685 }
1686
1687 let server = crate::http3::Http3Server::new(
1688 &config,
1689 Arc::new(self.router.clone()),
1690 Arc::new(self.layers.clone()),
1691 Arc::new(self.interceptors.clone()),
1692 )
1693 .await?;
1694
1695 server.run().await
1696 }
1697
1698 #[cfg(feature = "http3-dev")]
1712 pub async fn run_http3_dev(
1713 mut self,
1714 addr: &str,
1715 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1716 use std::sync::Arc;
1717
1718 self.apply_health_endpoints();
1720
1721 self.apply_status_page();
1723
1724 if let Some(limit) = self.body_limit {
1726 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1727 }
1728
1729 let server = crate::http3::Http3Server::new_with_self_signed(
1730 addr,
1731 Arc::new(self.router.clone()),
1732 Arc::new(self.layers.clone()),
1733 Arc::new(self.interceptors.clone()),
1734 )
1735 .await?;
1736
1737 server.run().await
1738 }
1739
1740 #[cfg(feature = "http3")]
1751 pub fn with_http3(mut self, cert_path: impl Into<String>, key_path: impl Into<String>) -> Self {
1752 self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path));
1753 self
1754 }
1755
1756 #[cfg(feature = "http3")]
1771 pub async fn run_dual_stack(
1772 mut self,
1773 http_addr: &str,
1774 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1775 use std::sync::Arc;
1776
1777 let mut config = self
1778 .http3_config
1779 .take()
1780 .ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
1781
1782 let http_socket: std::net::SocketAddr = http_addr.parse()?;
1783 config.bind_addr = if http_socket.ip().is_ipv6() {
1784 format!("[{}]", http_socket.ip())
1785 } else {
1786 http_socket.ip().to_string()
1787 };
1788 config.port = http_socket.port();
1789 let http_addr = http_socket.to_string();
1790
1791 self.apply_health_endpoints();
1793
1794 self.apply_status_page();
1796
1797 if let Some(limit) = self.body_limit {
1799 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1800 }
1801
1802 let router = Arc::new(self.router);
1803 let layers = Arc::new(self.layers);
1804 let interceptors = Arc::new(self.interceptors);
1805
1806 let http1_server =
1807 Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1808 let http3_server =
1809 crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1810
1811 tracing::info!(
1812 http1_addr = %http_addr,
1813 http3_addr = %config.socket_addr(),
1814 "Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1815 );
1816
1817 tokio::try_join!(
1818 http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1819 http3_server.run_with_shutdown(std::future::pending::<()>()),
1820 )?;
1821
1822 Ok(())
1823 }
1824}
1825
1826#[cfg(feature = "dashboard")]
1827fn openapi_tags_for_route(
1828 spec: &rustapi_openapi::OpenApiSpec,
1829 path: &str,
1830 methods: &[http::Method],
1831) -> Vec<String> {
1832 let Some(path_item) = spec.paths.get(path) else {
1833 return Vec::new();
1834 };
1835
1836 let mut tags = BTreeSet::new();
1837 for method in methods {
1838 if let Some(operation) = operation_for_method(path_item, method) {
1839 tags.extend(operation.tags.iter().cloned());
1840 }
1841 }
1842
1843 tags.into_iter().collect()
1844}
1845
1846#[cfg(feature = "dashboard")]
1847fn operation_for_method<'a>(
1848 path_item: &'a rustapi_openapi::PathItem,
1849 method: &http::Method,
1850) -> Option<&'a rustapi_openapi::Operation> {
1851 match *method {
1852 http::Method::GET => path_item.get.as_ref(),
1853 http::Method::POST => path_item.post.as_ref(),
1854 http::Method::PUT => path_item.put.as_ref(),
1855 http::Method::PATCH => path_item.patch.as_ref(),
1856 http::Method::DELETE => path_item.delete.as_ref(),
1857 http::Method::HEAD => path_item.head.as_ref(),
1858 http::Method::OPTIONS => path_item.options.as_ref(),
1859 http::Method::TRACE => path_item.trace.as_ref(),
1860 _ => None,
1861 }
1862}
1863
1864#[cfg(feature = "dashboard")]
1865fn infer_route_feature_gates(path: &str) -> Vec<String> {
1866 if path.contains("openapi") || path.contains("docs") {
1867 vec!["core-openapi".to_string()]
1868 } else if path.starts_with("/__rustapi/replays") {
1869 vec!["extras-replay".to_string()]
1870 } else {
1871 Vec::new()
1872 }
1873}
1874
1875#[cfg(feature = "dashboard")]
1876fn is_dashboard_replay_eligible(path: &str, health_eligible: bool) -> bool {
1877 !health_eligible && !path.starts_with("/__rustapi/")
1878}
1879
1880fn add_path_params_to_operation(
1881 path: &str,
1882 op: &mut rustapi_openapi::Operation,
1883 param_schemas: &BTreeMap<String, String>,
1884) {
1885 let mut params: Vec<String> = Vec::new();
1886 let mut in_brace = false;
1887 let mut current = String::new();
1888
1889 for ch in path.chars() {
1890 match ch {
1891 '{' => {
1892 in_brace = true;
1893 current.clear();
1894 }
1895 '}' => {
1896 if in_brace {
1897 in_brace = false;
1898 if !current.is_empty() {
1899 params.push(current.clone());
1900 }
1901 }
1902 }
1903 _ => {
1904 if in_brace {
1905 current.push(ch);
1906 }
1907 }
1908 }
1909 }
1910
1911 if params.is_empty() {
1912 return;
1913 }
1914
1915 let op_params = &mut op.parameters;
1916
1917 for name in params {
1918 let already = op_params
1919 .iter()
1920 .any(|p| p.location == "path" && p.name == name);
1921 if already {
1922 continue;
1923 }
1924
1925 let schema = if let Some(schema_type) = param_schemas.get(&name) {
1927 schema_type_to_openapi_schema(schema_type)
1928 } else {
1929 infer_path_param_schema(&name)
1930 };
1931
1932 op_params.push(rustapi_openapi::Parameter {
1933 name,
1934 location: "path".to_string(),
1935 required: true,
1936 description: None,
1937 deprecated: None,
1938 schema: Some(schema),
1939 });
1940 }
1941}
1942
1943fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef {
1945 match schema_type.to_lowercase().as_str() {
1946 "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1947 "type": "string",
1948 "format": "uuid"
1949 })),
1950 "integer" | "int" | "int64" | "i64" => {
1951 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1952 "type": "integer",
1953 "format": "int64"
1954 }))
1955 }
1956 "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1957 "type": "integer",
1958 "format": "int32"
1959 })),
1960 "number" | "float" | "f64" | "f32" => {
1961 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1962 "type": "number"
1963 }))
1964 }
1965 "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1966 "type": "boolean"
1967 })),
1968 _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1969 "type": "string"
1970 })),
1971 }
1972}
1973
1974fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
1983 let lower = name.to_lowercase();
1984
1985 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
1987
1988 if is_uuid {
1989 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1990 "type": "string",
1991 "format": "uuid"
1992 }));
1993 }
1994
1995 let is_integer = lower == "page"
1998 || lower == "limit"
1999 || lower == "offset"
2000 || lower == "count"
2001 || lower.ends_with("_count")
2002 || lower.ends_with("_num")
2003 || lower == "year"
2004 || lower == "month"
2005 || lower == "day"
2006 || lower == "index"
2007 || lower == "position";
2008
2009 if is_integer {
2010 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
2011 "type": "integer",
2012 "format": "int64"
2013 }))
2014 } else {
2015 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
2016 }
2017}
2018
2019fn normalize_prefix_for_openapi(prefix: &str) -> String {
2026 if prefix.is_empty() {
2028 return "/".to_string();
2029 }
2030
2031 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
2033
2034 if segments.is_empty() {
2036 return "/".to_string();
2037 }
2038
2039 let mut result = String::with_capacity(prefix.len() + 1);
2041 for segment in segments {
2042 result.push('/');
2043 result.push_str(segment);
2044 }
2045
2046 result
2047}
2048
2049impl Default for RustApi {
2050 fn default() -> Self {
2051 Self::new()
2052 }
2053}
2054
2055#[cfg(feature = "swagger-ui")]
2057fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
2058 req.headers()
2059 .get(http::header::AUTHORIZATION)
2060 .and_then(|v| v.to_str().ok())
2061 .map(|auth| auth == expected)
2062 .unwrap_or(false)
2063}
2064
2065#[cfg(feature = "swagger-ui")]
2067fn unauthorized_response() -> crate::Response {
2068 http::Response::builder()
2069 .status(http::StatusCode::UNAUTHORIZED)
2070 .header(
2071 http::header::WWW_AUTHENTICATE,
2072 "Basic realm=\"API Documentation\"",
2073 )
2074 .header(http::header::CONTENT_TYPE, "text/plain")
2075 .body(crate::response::Body::from("Unauthorized"))
2076 .unwrap()
2077}
2078
2079pub struct RustApiConfig {
2081 docs_path: Option<String>,
2082 docs_enabled: bool,
2083 api_title: String,
2084 api_version: String,
2085 api_description: Option<String>,
2086 body_limit: Option<usize>,
2087 layers: LayerStack,
2088}
2089
2090impl Default for RustApiConfig {
2091 fn default() -> Self {
2092 Self::new()
2093 }
2094}
2095
2096impl RustApiConfig {
2097 pub fn new() -> Self {
2098 Self {
2099 docs_path: Some("/docs".to_string()),
2100 docs_enabled: true,
2101 api_title: "RustAPI".to_string(),
2102 api_version: "1.0.0".to_string(),
2103 api_description: None,
2104 body_limit: None,
2105 layers: LayerStack::new(),
2106 }
2107 }
2108
2109 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
2111 self.docs_path = Some(path.into());
2112 self
2113 }
2114
2115 pub fn docs_enabled(mut self, enabled: bool) -> Self {
2117 self.docs_enabled = enabled;
2118 self
2119 }
2120
2121 pub fn openapi_info(
2123 mut self,
2124 title: impl Into<String>,
2125 version: impl Into<String>,
2126 description: Option<impl Into<String>>,
2127 ) -> Self {
2128 self.api_title = title.into();
2129 self.api_version = version.into();
2130 self.api_description = description.map(|d| d.into());
2131 self
2132 }
2133
2134 pub fn body_limit(mut self, limit: usize) -> Self {
2136 self.body_limit = Some(limit);
2137 self
2138 }
2139
2140 pub fn layer<L>(mut self, layer: L) -> Self
2142 where
2143 L: MiddlewareLayer,
2144 {
2145 self.layers.push(Box::new(layer));
2146 self
2147 }
2148
2149 pub fn build(self) -> RustApi {
2151 let mut app = RustApi::new().mount_auto_routes_grouped();
2152
2153 if let Some(limit) = self.body_limit {
2155 app = app.body_limit(limit);
2156 }
2157
2158 app = app.openapi_info(
2159 &self.api_title,
2160 &self.api_version,
2161 self.api_description.as_deref(),
2162 );
2163
2164 #[cfg(feature = "swagger-ui")]
2165 if self.docs_enabled {
2166 if let Some(path) = self.docs_path {
2167 app = app.docs(&path);
2168 }
2169 }
2170
2171 app.layers.extend(self.layers);
2174
2175 app
2176 }
2177
2178 pub async fn run(
2180 self,
2181 addr: impl AsRef<str>,
2182 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2183 self.build().run(addr.as_ref()).await
2184 }
2185}
2186
2187#[cfg(test)]
2188mod tests {
2189 use super::RustApi;
2190 use crate::extract::{FromRequestParts, State};
2191 use crate::path_params::PathParams;
2192 use crate::request::Request;
2193 use crate::router::{get, post, Router};
2194 use bytes::Bytes;
2195 use http::Method;
2196 use proptest::prelude::*;
2197
2198 #[test]
2199 fn state_is_available_via_extractor() {
2200 let app = RustApi::new().state(123u32);
2201 let router = app.into_router();
2202
2203 let req = http::Request::builder()
2204 .method(Method::GET)
2205 .uri("/test")
2206 .body(())
2207 .unwrap();
2208 let (parts, _) = req.into_parts();
2209
2210 let request = Request::new(
2211 parts,
2212 crate::request::BodyVariant::Buffered(Bytes::new()),
2213 router.state_ref(),
2214 PathParams::new(),
2215 );
2216 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
2217 assert_eq!(value, 123u32);
2218 }
2219
2220 #[test]
2221 fn test_path_param_type_inference_integer() {
2222 use super::infer_path_param_schema;
2223
2224 let int_params = [
2226 "page",
2227 "limit",
2228 "offset",
2229 "count",
2230 "item_count",
2231 "year",
2232 "month",
2233 "day",
2234 "index",
2235 "position",
2236 ];
2237
2238 for name in int_params {
2239 let schema = infer_path_param_schema(name);
2240 match schema {
2241 rustapi_openapi::SchemaRef::Inline(v) => {
2242 assert_eq!(
2243 v.get("type").and_then(|v| v.as_str()),
2244 Some("integer"),
2245 "Expected '{}' to be inferred as integer",
2246 name
2247 );
2248 }
2249 _ => panic!("Expected inline schema for '{}'", name),
2250 }
2251 }
2252 }
2253
2254 #[test]
2255 fn test_path_param_type_inference_uuid() {
2256 use super::infer_path_param_schema;
2257
2258 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
2260
2261 for name in uuid_params {
2262 let schema = infer_path_param_schema(name);
2263 match schema {
2264 rustapi_openapi::SchemaRef::Inline(v) => {
2265 assert_eq!(
2266 v.get("type").and_then(|v| v.as_str()),
2267 Some("string"),
2268 "Expected '{}' to be inferred as string",
2269 name
2270 );
2271 assert_eq!(
2272 v.get("format").and_then(|v| v.as_str()),
2273 Some("uuid"),
2274 "Expected '{}' to have uuid format",
2275 name
2276 );
2277 }
2278 _ => panic!("Expected inline schema for '{}'", name),
2279 }
2280 }
2281 }
2282
2283 #[test]
2284 fn test_path_param_type_inference_string() {
2285 use super::infer_path_param_schema;
2286
2287 let string_params = [
2289 "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId",
2290 ];
2291
2292 for name in string_params {
2293 let schema = infer_path_param_schema(name);
2294 match schema {
2295 rustapi_openapi::SchemaRef::Inline(v) => {
2296 assert_eq!(
2297 v.get("type").and_then(|v| v.as_str()),
2298 Some("string"),
2299 "Expected '{}' to be inferred as string",
2300 name
2301 );
2302 assert!(
2303 v.get("format").is_none()
2304 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
2305 "Expected '{}' to NOT have uuid format",
2306 name
2307 );
2308 }
2309 _ => panic!("Expected inline schema for '{}'", name),
2310 }
2311 }
2312 }
2313
2314 #[test]
2315 fn test_schema_type_to_openapi_schema() {
2316 use super::schema_type_to_openapi_schema;
2317
2318 let uuid_schema = schema_type_to_openapi_schema("uuid");
2320 match uuid_schema {
2321 rustapi_openapi::SchemaRef::Inline(v) => {
2322 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2323 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid"));
2324 }
2325 _ => panic!("Expected inline schema for uuid"),
2326 }
2327
2328 for schema_type in ["integer", "int", "int64", "i64"] {
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("integer"));
2334 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64"));
2335 }
2336 _ => panic!("Expected inline schema for {}", schema_type),
2337 }
2338 }
2339
2340 let int32_schema = schema_type_to_openapi_schema("int32");
2342 match int32_schema {
2343 rustapi_openapi::SchemaRef::Inline(v) => {
2344 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
2345 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32"));
2346 }
2347 _ => panic!("Expected inline schema for int32"),
2348 }
2349
2350 for schema_type in ["number", "float"] {
2352 let schema = schema_type_to_openapi_schema(schema_type);
2353 match schema {
2354 rustapi_openapi::SchemaRef::Inline(v) => {
2355 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number"));
2356 }
2357 _ => panic!("Expected inline schema for {}", schema_type),
2358 }
2359 }
2360
2361 for schema_type in ["boolean", "bool"] {
2363 let schema = schema_type_to_openapi_schema(schema_type);
2364 match schema {
2365 rustapi_openapi::SchemaRef::Inline(v) => {
2366 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean"));
2367 }
2368 _ => panic!("Expected inline schema for {}", schema_type),
2369 }
2370 }
2371
2372 let string_schema = schema_type_to_openapi_schema("string");
2374 match string_schema {
2375 rustapi_openapi::SchemaRef::Inline(v) => {
2376 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2377 }
2378 _ => panic!("Expected inline schema for string"),
2379 }
2380 }
2381
2382 proptest! {
2389 #![proptest_config(ProptestConfig::with_cases(100))]
2390
2391 #[test]
2396 fn prop_nested_routes_in_openapi_spec(
2397 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2399 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2401 has_param in any::<bool>(),
2402 ) {
2403 async fn handler() -> &'static str { "handler" }
2404
2405 let prefix = format!("/{}", prefix_segments.join("/"));
2407
2408 let mut route_path = format!("/{}", route_segments.join("/"));
2410 if has_param {
2411 route_path.push_str("/{id}");
2412 }
2413
2414 let nested_router = Router::new().route(&route_path, get(handler));
2416 let app = RustApi::new().nest(&prefix, nested_router);
2417
2418 let expected_openapi_path = format!("{}{}", prefix, route_path);
2420
2421 let spec = app.openapi_spec();
2423
2424 prop_assert!(
2426 spec.paths.contains_key(&expected_openapi_path),
2427 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2428 expected_openapi_path,
2429 spec.paths.keys().collect::<Vec<_>>()
2430 );
2431
2432 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2434 prop_assert!(
2435 path_item.get.is_some(),
2436 "GET operation should exist for path '{}'",
2437 expected_openapi_path
2438 );
2439 }
2440
2441 #[test]
2446 fn prop_multiple_methods_preserved_in_openapi(
2447 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2448 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2449 ) {
2450 async fn get_handler() -> &'static str { "get" }
2451 async fn post_handler() -> &'static str { "post" }
2452
2453 let prefix = format!("/{}", prefix_segments.join("/"));
2455 let route_path = format!("/{}", route_segments.join("/"));
2456
2457 let get_route_path = format!("{}/get", route_path);
2460 let post_route_path = format!("{}/post", route_path);
2461 let nested_router = Router::new()
2462 .route(&get_route_path, get(get_handler))
2463 .route(&post_route_path, post(post_handler));
2464 let app = RustApi::new().nest(&prefix, nested_router);
2465
2466 let expected_get_path = format!("{}{}", prefix, get_route_path);
2468 let expected_post_path = format!("{}{}", prefix, post_route_path);
2469
2470 let spec = app.openapi_spec();
2472
2473 prop_assert!(
2475 spec.paths.contains_key(&expected_get_path),
2476 "Expected OpenAPI path '{}' not found",
2477 expected_get_path
2478 );
2479 prop_assert!(
2480 spec.paths.contains_key(&expected_post_path),
2481 "Expected OpenAPI path '{}' not found",
2482 expected_post_path
2483 );
2484
2485 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
2487 prop_assert!(
2488 get_path_item.get.is_some(),
2489 "GET operation should exist for path '{}'",
2490 expected_get_path
2491 );
2492
2493 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
2495 prop_assert!(
2496 post_path_item.post.is_some(),
2497 "POST operation should exist for path '{}'",
2498 expected_post_path
2499 );
2500 }
2501
2502 #[test]
2507 fn prop_path_params_in_openapi_after_nesting(
2508 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2509 param_name in "[a-z][a-z0-9]{0,5}",
2510 ) {
2511 async fn handler() -> &'static str { "handler" }
2512
2513 let prefix = format!("/{}", prefix_segments.join("/"));
2515 let route_path = format!("/{{{}}}", param_name);
2516
2517 let nested_router = Router::new().route(&route_path, get(handler));
2519 let app = RustApi::new().nest(&prefix, nested_router);
2520
2521 let expected_openapi_path = format!("{}{}", prefix, route_path);
2523
2524 let spec = app.openapi_spec();
2526
2527 prop_assert!(
2529 spec.paths.contains_key(&expected_openapi_path),
2530 "Expected OpenAPI path '{}' not found",
2531 expected_openapi_path
2532 );
2533
2534 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2536 let get_op = path_item.get.as_ref().unwrap();
2537
2538 prop_assert!(
2539 !get_op.parameters.is_empty(),
2540 "Operation should have parameters for path '{}'",
2541 expected_openapi_path
2542 );
2543
2544 let params = &get_op.parameters;
2545 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
2546 prop_assert!(
2547 has_param,
2548 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
2549 param_name,
2550 params.iter().map(|p| &p.name).collect::<Vec<_>>()
2551 );
2552 }
2553 }
2554
2555 proptest! {
2563 #![proptest_config(ProptestConfig::with_cases(100))]
2564
2565 #[test]
2570 fn prop_rustapi_nest_delegates_to_router_nest(
2571 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2572 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2573 has_param in any::<bool>(),
2574 ) {
2575 async fn handler() -> &'static str { "handler" }
2576
2577 let prefix = format!("/{}", prefix_segments.join("/"));
2579
2580 let mut route_path = format!("/{}", route_segments.join("/"));
2582 if has_param {
2583 route_path.push_str("/{id}");
2584 }
2585
2586 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2588 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2589
2590 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2592 let rustapi_router = rustapi_app.into_router();
2593
2594 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2596
2597 let rustapi_routes = rustapi_router.registered_routes();
2599 let router_routes = router_app.registered_routes();
2600
2601 prop_assert_eq!(
2602 rustapi_routes.len(),
2603 router_routes.len(),
2604 "RustApi and Router should have same number of routes"
2605 );
2606
2607 for (path, info) in router_routes {
2609 prop_assert!(
2610 rustapi_routes.contains_key(path),
2611 "Route '{}' from Router should exist in RustApi routes",
2612 path
2613 );
2614
2615 let rustapi_info = rustapi_routes.get(path).unwrap();
2616 prop_assert_eq!(
2617 &info.path, &rustapi_info.path,
2618 "Display paths should match for route '{}'",
2619 path
2620 );
2621 prop_assert_eq!(
2622 info.methods.len(), rustapi_info.methods.len(),
2623 "Method count should match for route '{}'",
2624 path
2625 );
2626 }
2627 }
2628
2629 #[test]
2634 fn prop_rustapi_nest_includes_routes_in_openapi(
2635 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2636 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2637 has_param in any::<bool>(),
2638 ) {
2639 async fn handler() -> &'static str { "handler" }
2640
2641 let prefix = format!("/{}", prefix_segments.join("/"));
2643
2644 let mut route_path = format!("/{}", route_segments.join("/"));
2646 if has_param {
2647 route_path.push_str("/{id}");
2648 }
2649
2650 let nested_router = Router::new().route(&route_path, get(handler));
2652 let app = RustApi::new().nest(&prefix, nested_router);
2653
2654 let expected_openapi_path = format!("{}{}", prefix, route_path);
2656
2657 let spec = app.openapi_spec();
2659
2660 prop_assert!(
2662 spec.paths.contains_key(&expected_openapi_path),
2663 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2664 expected_openapi_path,
2665 spec.paths.keys().collect::<Vec<_>>()
2666 );
2667
2668 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2670 prop_assert!(
2671 path_item.get.is_some(),
2672 "GET operation should exist for path '{}'",
2673 expected_openapi_path
2674 );
2675 }
2676
2677 #[test]
2682 fn prop_rustapi_nest_route_matching_identical(
2683 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2684 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2685 param_value in "[a-z0-9]{1,10}",
2686 ) {
2687 use crate::router::RouteMatch;
2688
2689 async fn handler() -> &'static str { "handler" }
2690
2691 let prefix = format!("/{}", prefix_segments.join("/"));
2693 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
2694
2695 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2697 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2698
2699 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2701 let rustapi_router = rustapi_app.into_router();
2702 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2703
2704 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
2706
2707 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
2709 let router_match = router_app.match_route(&full_path, &Method::GET);
2710
2711 match (rustapi_match, router_match) {
2713 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
2714 prop_assert_eq!(
2715 rustapi_params.len(),
2716 router_params.len(),
2717 "Parameter count should match"
2718 );
2719 for (key, value) in &router_params {
2720 prop_assert!(
2721 rustapi_params.contains_key(key),
2722 "RustApi should have parameter '{}'",
2723 key
2724 );
2725 prop_assert_eq!(
2726 rustapi_params.get(key).unwrap(),
2727 value,
2728 "Parameter '{}' value should match",
2729 key
2730 );
2731 }
2732 }
2733 (rustapi_result, router_result) => {
2734 prop_assert!(
2735 false,
2736 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
2737 match rustapi_result {
2738 RouteMatch::Found { .. } => "Found",
2739 RouteMatch::NotFound => "NotFound",
2740 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2741 },
2742 match router_result {
2743 RouteMatch::Found { .. } => "Found",
2744 RouteMatch::NotFound => "NotFound",
2745 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2746 }
2747 );
2748 }
2749 }
2750 }
2751 }
2752
2753 #[test]
2755 fn test_openapi_operations_propagated_during_nesting() {
2756 async fn list_users() -> &'static str {
2757 "list users"
2758 }
2759 async fn get_user() -> &'static str {
2760 "get user"
2761 }
2762 async fn create_user() -> &'static str {
2763 "create user"
2764 }
2765
2766 let users_router = Router::new()
2769 .route("/", get(list_users))
2770 .route("/create", post(create_user))
2771 .route("/{id}", get(get_user));
2772
2773 let app = RustApi::new().nest("/api/v1/users", users_router);
2775
2776 let spec = app.openapi_spec();
2777
2778 assert!(
2780 spec.paths.contains_key("/api/v1/users"),
2781 "Should have /api/v1/users path"
2782 );
2783 let users_path = spec.paths.get("/api/v1/users").unwrap();
2784 assert!(users_path.get.is_some(), "Should have GET operation");
2785
2786 assert!(
2788 spec.paths.contains_key("/api/v1/users/create"),
2789 "Should have /api/v1/users/create path"
2790 );
2791 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
2792 assert!(create_path.post.is_some(), "Should have POST operation");
2793
2794 assert!(
2796 spec.paths.contains_key("/api/v1/users/{id}"),
2797 "Should have /api/v1/users/{{id}} path"
2798 );
2799 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
2800 assert!(
2801 user_path.get.is_some(),
2802 "Should have GET operation for user by id"
2803 );
2804
2805 let get_user_op = user_path.get.as_ref().unwrap();
2807 assert!(!get_user_op.parameters.is_empty(), "Should have parameters");
2808 let params = &get_user_op.parameters;
2809 assert!(
2810 params
2811 .iter()
2812 .any(|p| p.name == "id" && p.location == "path"),
2813 "Should have 'id' path parameter"
2814 );
2815 }
2816
2817 #[test]
2819 fn test_openapi_spec_empty_without_routes() {
2820 let app = RustApi::new();
2821 let spec = app.openapi_spec();
2822
2823 assert!(
2825 spec.paths.is_empty(),
2826 "OpenAPI spec should have no paths without routes"
2827 );
2828 }
2829
2830 #[test]
2835 fn test_rustapi_nest_delegates_to_router_nest() {
2836 use crate::router::RouteMatch;
2837
2838 async fn list_users() -> &'static str {
2839 "list users"
2840 }
2841 async fn get_user() -> &'static str {
2842 "get user"
2843 }
2844 async fn create_user() -> &'static str {
2845 "create user"
2846 }
2847
2848 let users_router = Router::new()
2850 .route("/", get(list_users))
2851 .route("/create", post(create_user))
2852 .route("/{id}", get(get_user));
2853
2854 let app = RustApi::new().nest("/api/v1/users", users_router);
2856 let router = app.into_router();
2857
2858 let routes = router.registered_routes();
2860 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
2861
2862 assert!(
2864 routes.contains_key("/api/v1/users"),
2865 "Should have /api/v1/users route"
2866 );
2867 assert!(
2868 routes.contains_key("/api/v1/users/create"),
2869 "Should have /api/v1/users/create route"
2870 );
2871 assert!(
2872 routes.contains_key("/api/v1/users/:id"),
2873 "Should have /api/v1/users/:id route"
2874 );
2875
2876 match router.match_route("/api/v1/users", &Method::GET) {
2878 RouteMatch::Found { params, .. } => {
2879 assert!(params.is_empty(), "Root route should have no params");
2880 }
2881 _ => panic!("GET /api/v1/users should be found"),
2882 }
2883
2884 match router.match_route("/api/v1/users/create", &Method::POST) {
2885 RouteMatch::Found { params, .. } => {
2886 assert!(params.is_empty(), "Create route should have no params");
2887 }
2888 _ => panic!("POST /api/v1/users/create should be found"),
2889 }
2890
2891 match router.match_route("/api/v1/users/123", &Method::GET) {
2892 RouteMatch::Found { params, .. } => {
2893 assert_eq!(
2894 params.get("id"),
2895 Some(&"123".to_string()),
2896 "Should extract id param"
2897 );
2898 }
2899 _ => panic!("GET /api/v1/users/123 should be found"),
2900 }
2901
2902 match router.match_route("/api/v1/users", &Method::DELETE) {
2904 RouteMatch::MethodNotAllowed { allowed } => {
2905 assert!(allowed.contains(&Method::GET), "Should allow GET");
2906 }
2907 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
2908 }
2909 }
2910
2911 #[test]
2916 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
2917 async fn list_items() -> &'static str {
2918 "list items"
2919 }
2920 async fn get_item() -> &'static str {
2921 "get item"
2922 }
2923
2924 let items_router = Router::new()
2926 .route("/", get(list_items))
2927 .route("/{item_id}", get(get_item));
2928
2929 let app = RustApi::new().nest("/api/items", items_router);
2931
2932 let spec = app.openapi_spec();
2934
2935 assert!(
2937 spec.paths.contains_key("/api/items"),
2938 "Should have /api/items in OpenAPI"
2939 );
2940 assert!(
2941 spec.paths.contains_key("/api/items/{item_id}"),
2942 "Should have /api/items/{{item_id}} in OpenAPI"
2943 );
2944
2945 let list_path = spec.paths.get("/api/items").unwrap();
2947 assert!(
2948 list_path.get.is_some(),
2949 "Should have GET operation for /api/items"
2950 );
2951
2952 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
2953 assert!(
2954 get_path.get.is_some(),
2955 "Should have GET operation for /api/items/{{item_id}}"
2956 );
2957
2958 let get_op = get_path.get.as_ref().unwrap();
2960 assert!(!get_op.parameters.is_empty(), "Should have parameters");
2961 let params = &get_op.parameters;
2962 assert!(
2963 params
2964 .iter()
2965 .any(|p| p.name == "item_id" && p.location == "path"),
2966 "Should have 'item_id' path parameter"
2967 );
2968 }
2969}