1use crate::error::Result;
4use crate::events::LifecycleHooks;
5use crate::interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor};
6use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT};
7use crate::response::IntoResponse;
8use crate::router::{MethodRouter, Router};
9use crate::server::Server;
10use std::collections::BTreeMap;
11#[cfg(feature = "dashboard")]
12use std::collections::BTreeSet;
13use std::future::Future;
14use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
15
16pub struct RustApi {
34 router: Router,
35 openapi_spec: rustapi_openapi::OpenApiSpec,
36 layers: LayerStack,
37 body_limit: Option<usize>,
38 interceptors: InterceptorChain,
39 lifecycle_hooks: LifecycleHooks,
40 hot_reload: bool,
41 #[cfg(feature = "http3")]
42 http3_config: Option<crate::http3::Http3Config>,
43 health_check: Option<crate::health::HealthCheck>,
44 health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
45 status_config: Option<crate::status::StatusConfig>,
46 #[cfg(feature = "dashboard")]
47 dashboard_config: Option<crate::dashboard::DashboardConfig>,
48}
49
50#[derive(Debug, Clone)]
58pub struct ProductionDefaultsConfig {
59 service_name: String,
60 version: Option<String>,
61 tracing_level: tracing::Level,
62 health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
63 enable_request_id: bool,
64 enable_tracing: bool,
65 enable_health_endpoints: bool,
66}
67
68impl ProductionDefaultsConfig {
69 pub fn new(service_name: impl Into<String>) -> Self {
71 Self {
72 service_name: service_name.into(),
73 version: None,
74 tracing_level: tracing::Level::INFO,
75 health_endpoint_config: None,
76 enable_request_id: true,
77 enable_tracing: true,
78 enable_health_endpoints: true,
79 }
80 }
81
82 pub fn version(mut self, version: impl Into<String>) -> Self {
84 self.version = Some(version.into());
85 self
86 }
87
88 pub fn tracing_level(mut self, level: tracing::Level) -> Self {
90 self.tracing_level = level;
91 self
92 }
93
94 pub fn health_endpoint_config(mut self, config: crate::health::HealthEndpointConfig) -> Self {
96 self.health_endpoint_config = Some(config);
97 self
98 }
99
100 pub fn request_id(mut self, enabled: bool) -> Self {
102 self.enable_request_id = enabled;
103 self
104 }
105
106 pub fn tracing(mut self, enabled: bool) -> Self {
108 self.enable_tracing = enabled;
109 self
110 }
111
112 pub fn health_endpoints(mut self, enabled: bool) -> Self {
114 self.enable_health_endpoints = enabled;
115 self
116 }
117}
118
119impl RustApi {
120 pub fn new() -> Self {
122 let _ = tracing_subscriber::registry()
124 .with(
125 EnvFilter::try_from_default_env()
126 .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
127 )
128 .with(tracing_subscriber::fmt::layer())
129 .try_init();
130
131 Self {
132 router: Router::new(),
133 openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
134 .register::<rustapi_openapi::ErrorSchema>()
135 .register::<rustapi_openapi::ErrorBodySchema>()
136 .register::<rustapi_openapi::ValidationErrorSchema>()
137 .register::<rustapi_openapi::ValidationErrorBodySchema>()
138 .register::<rustapi_openapi::FieldErrorSchema>(),
139 layers: LayerStack::new(),
140 body_limit: Some(DEFAULT_BODY_LIMIT), interceptors: InterceptorChain::new(),
142 lifecycle_hooks: LifecycleHooks::new(),
143 hot_reload: false,
144 #[cfg(feature = "http3")]
145 http3_config: None,
146 health_check: None,
147 health_endpoint_config: None,
148 status_config: None,
149 #[cfg(feature = "dashboard")]
150 dashboard_config: None,
151 }
152 }
153
154 #[cfg(feature = "swagger-ui")]
184 pub fn auto() -> Self {
185 Self::new().mount_auto_routes_grouped().docs("/docs")
186 }
187
188 #[cfg(not(feature = "swagger-ui"))]
189 pub fn auto() -> Self {
190 Self::new().mount_auto_routes_grouped()
191 }
192
193 pub fn config() -> RustApiConfig {
211 RustApiConfig::new()
212 }
213
214 pub fn body_limit(mut self, limit: usize) -> Self {
235 self.body_limit = Some(limit);
236 self
237 }
238
239 pub fn no_body_limit(mut self) -> Self {
252 self.body_limit = None;
253 self
254 }
255
256 pub fn layer<L>(mut self, layer: L) -> Self
276 where
277 L: MiddlewareLayer,
278 {
279 self.layers.push(Box::new(layer));
280 self
281 }
282
283 pub fn request_interceptor<I>(mut self, interceptor: I) -> Self
315 where
316 I: RequestInterceptor,
317 {
318 self.interceptors.add_request_interceptor(interceptor);
319 self
320 }
321
322 pub fn response_interceptor<I>(mut self, interceptor: I) -> Self
354 where
355 I: ResponseInterceptor,
356 {
357 self.interceptors.add_response_interceptor(interceptor);
358 self
359 }
360
361 pub fn state<S>(self, _state: S) -> Self
377 where
378 S: Clone + Send + Sync + 'static,
379 {
380 let state = _state;
382 let mut app = self;
383 app.router = app.router.state(state);
384 app
385 }
386
387 pub fn on_start<F, Fut>(mut self, hook: F) -> Self
404 where
405 F: FnOnce() -> Fut + Send + 'static,
406 Fut: Future<Output = ()> + Send + 'static,
407 {
408 self.lifecycle_hooks
409 .on_start
410 .push(Box::new(move || Box::pin(hook())));
411 self
412 }
413
414 pub fn on_shutdown<F, Fut>(mut self, hook: F) -> Self
431 where
432 F: FnOnce() -> Fut + Send + 'static,
433 Fut: Future<Output = ()> + Send + 'static,
434 {
435 self.lifecycle_hooks
436 .on_shutdown
437 .push(Box::new(move || Box::pin(hook())));
438 self
439 }
440
441 pub fn hot_reload(mut self, enabled: bool) -> Self {
460 self.hot_reload = enabled;
461 self
462 }
463
464 pub fn register_schema<T: rustapi_openapi::schema::RustApiSchema>(mut self) -> Self {
476 self.openapi_spec = self.openapi_spec.register::<T>();
477 self
478 }
479
480 pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
482 self.openapi_spec.info.title = title.to_string();
485 self.openapi_spec.info.version = version.to_string();
486 self.openapi_spec.info.description = description.map(|d| d.to_string());
487 self
488 }
489
490 pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
492 &self.openapi_spec
493 }
494
495 fn mount_auto_routes_grouped(mut self) -> Self {
496 let routes = crate::auto_route::collect_auto_routes();
497
498 if routes.is_empty() {
499 tracing::warn!(
502 target: "rustapi::auto",
503 count = 0,
504 "RustApi::auto() collected 0 routes. \
505 This usually means either:\n\
506 - No handlers were annotated with #[rustapi_rs::get], #[post], etc.\n\
507 - The binary/test was not linked with the annotated modules (common in some test setups).\n\
508 - You are building a library (cdylib/rlib) where linkme distributed slices may not be populated.\n\n\
509 You can still register routes manually with .route() or check with rustapi_rs::auto_route_count()."
510 );
511 } else {
512 #[cfg(feature = "tracing")]
513 tracing::debug!(
514 target: "rustapi::auto",
515 count = routes.len(),
516 "Auto route collection found handlers"
517 );
518 }
519
520 let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();
522
523 for route in routes {
524 let crate::handler::Route {
525 path: route_path,
526 method,
527 handler,
528 operation,
529 component_registrar,
530 ..
531 } = route;
532
533 let method_enum = match method {
534 "GET" => http::Method::GET,
535 "POST" => http::Method::POST,
536 "PUT" => http::Method::PUT,
537 "DELETE" => http::Method::DELETE,
538 "PATCH" => http::Method::PATCH,
539 _ => http::Method::GET,
540 };
541
542 let path = if route_path.starts_with('/') {
543 route_path.to_string()
544 } else {
545 format!("/{}", route_path)
546 };
547
548 let entry = by_path.entry(path).or_default();
549 entry.insert_boxed_with_operation(method_enum, handler, operation, component_registrar);
550 }
551
552 #[cfg(feature = "tracing")]
553 let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
554 #[cfg(feature = "tracing")]
555 let path_count = by_path.len();
556
557 for (path, method_router) in by_path {
558 self = self.route(&path, method_router);
559 }
560
561 crate::trace_info!(
562 paths = path_count,
563 routes = route_count,
564 "Auto-registered routes"
565 );
566
567 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
569
570 self
571 }
572
573 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
584 for register_components in &method_router.component_registrars {
585 register_components(&mut self.openapi_spec);
586 }
587
588 for (method, op) in &method_router.operations {
590 let mut op = op.clone();
591 add_path_params_to_operation(path, &mut op, &BTreeMap::new());
592 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
593 }
594
595 self.router = self.router.route(path, method_router);
596 self
597 }
598
599 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
601 self.route(P::PATH, method_router)
602 }
603
604 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
608 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
609 self.route(path, method_router)
610 }
611
612 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
630 let method_enum = match route.method {
631 "GET" => http::Method::GET,
632 "POST" => http::Method::POST,
633 "PUT" => http::Method::PUT,
634 "DELETE" => http::Method::DELETE,
635 "PATCH" => http::Method::PATCH,
636 _ => http::Method::GET,
637 };
638
639 (route.component_registrar)(&mut self.openapi_spec);
640
641 let mut op = route.operation;
643 add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
644 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
645
646 self.route_with_method(route.path, method_enum, route.handler)
647 }
648
649 fn route_with_method(
651 self,
652 path: &str,
653 method: http::Method,
654 handler: crate::handler::BoxedHandler,
655 ) -> Self {
656 use crate::router::MethodRouter;
657 let path = if !path.starts_with('/') {
666 format!("/{}", path)
667 } else {
668 path.to_string()
669 };
670
671 let mut handlers = std::collections::HashMap::new();
680 handlers.insert(method, handler);
681
682 let method_router = MethodRouter::from_boxed(handlers);
683 self.route(&path, method_router)
684 }
685
686 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
702 let normalized_prefix = normalize_prefix_for_openapi(prefix);
704
705 for (matchit_path, method_router) in router.method_routers() {
708 for register_components in &method_router.component_registrars {
709 register_components(&mut self.openapi_spec);
710 }
711
712 let display_path = router
714 .registered_routes()
715 .get(matchit_path)
716 .map(|info| info.path.clone())
717 .unwrap_or_else(|| matchit_path.clone());
718
719 let prefixed_path = if display_path == "/" {
721 normalized_prefix.clone()
722 } else {
723 format!("{}{}", normalized_prefix, display_path)
724 };
725
726 for (method, op) in &method_router.operations {
728 let mut op = op.clone();
729 add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
730 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
731 }
732 }
733
734 self.router = self.router.nest(prefix, router);
736 self
737 }
738
739 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
768 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
769 }
770
771 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
788 use crate::router::MethodRouter;
789 use std::collections::HashMap;
790
791 let prefix = config.prefix.clone();
792 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
793
794 let handler: crate::handler::BoxedHandler =
796 std::sync::Arc::new(move |req: crate::Request| {
797 let config = config.clone();
798 let path = req.uri().path().to_string();
799
800 Box::pin(async move {
801 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
802
803 match crate::static_files::StaticFile::serve(relative_path, &config).await {
804 Ok(response) => response,
805 Err(err) => err.into_response(),
806 }
807 })
808 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
809 });
810
811 let mut handlers = HashMap::new();
812 handlers.insert(http::Method::GET, handler);
813 let method_router = MethodRouter::from_boxed(handlers);
814
815 self.route(&catch_all_path, method_router)
816 }
817
818 #[cfg(feature = "compression")]
835 pub fn compression(self) -> Self {
836 self.layer(crate::middleware::CompressionLayer::new())
837 }
838
839 #[cfg(feature = "compression")]
855 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
856 self.layer(crate::middleware::CompressionLayer::with_config(config))
857 }
858
859 #[cfg(feature = "swagger-ui")]
883 pub fn docs(self, path: &str) -> Self {
884 let title = self.openapi_spec.info.title.clone();
885 let version = self.openapi_spec.info.version.clone();
886 let description = self.openapi_spec.info.description.clone();
887
888 self.docs_with_info(path, &title, &version, description.as_deref())
889 }
890
891 #[cfg(feature = "swagger-ui")]
900 pub fn docs_with_info(
901 mut self,
902 path: &str,
903 title: &str,
904 version: &str,
905 description: Option<&str>,
906 ) -> Self {
907 use crate::router::get;
908 self.openapi_spec.info.title = title.to_string();
910 self.openapi_spec.info.version = version.to_string();
911 if let Some(desc) = description {
912 self.openapi_spec.info.description = Some(desc.to_string());
913 }
914
915 let path = path.trim_end_matches('/');
916 let openapi_path = format!("{}/openapi.json", path);
917
918 let spec_value = self.openapi_spec.to_json();
920 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
921 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
923 "{}".to_string()
924 });
925 let openapi_url = openapi_path.clone();
926
927 let spec_handler = move || {
929 let json = spec_json.clone();
930 async move {
931 http::Response::builder()
932 .status(http::StatusCode::OK)
933 .header(http::header::CONTENT_TYPE, "application/json")
934 .body(crate::response::Body::from(json))
935 .unwrap_or_else(|e| {
936 tracing::error!("Failed to build response: {}", e);
937 http::Response::builder()
938 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
939 .body(crate::response::Body::from("Internal Server Error"))
940 .unwrap()
941 })
942 }
943 };
944
945 let docs_handler = move || {
947 let url = openapi_url.clone();
948 async move {
949 let response = rustapi_openapi::swagger_ui_html(&url);
950 response.map(crate::response::Body::Full)
951 }
952 };
953
954 self.route(&openapi_path, get(spec_handler))
955 .route(path, get(docs_handler))
956 }
957
958 #[cfg(feature = "swagger-ui")]
974 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
975 let title = self.openapi_spec.info.title.clone();
976 let version = self.openapi_spec.info.version.clone();
977 let description = self.openapi_spec.info.description.clone();
978
979 self.docs_with_auth_and_info(
980 path,
981 username,
982 password,
983 &title,
984 &version,
985 description.as_deref(),
986 )
987 }
988
989 #[cfg(feature = "swagger-ui")]
1005 pub fn docs_with_auth_and_info(
1006 mut self,
1007 path: &str,
1008 username: &str,
1009 password: &str,
1010 title: &str,
1011 version: &str,
1012 description: Option<&str>,
1013 ) -> Self {
1014 use crate::router::MethodRouter;
1015 use std::collections::HashMap;
1016
1017 #[inline]
1018 fn base64_encode(input: &[u8]) -> String {
1019 const ALPHA: &[u8; 64] =
1020 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1021 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1022 for chunk in input.chunks(3) {
1023 let b0 = chunk[0] as usize;
1024 let b1 = if chunk.len() > 1 {
1025 chunk[1] as usize
1026 } else {
1027 0
1028 };
1029 let b2 = if chunk.len() > 2 {
1030 chunk[2] as usize
1031 } else {
1032 0
1033 };
1034 out.push(ALPHA[b0 >> 2] as char);
1035 out.push(ALPHA[((b0 & 3) << 4) | (b1 >> 4)] as char);
1036 out.push(if chunk.len() > 1 {
1037 ALPHA[((b1 & 0xf) << 2) | (b2 >> 6)] as char
1038 } else {
1039 '='
1040 });
1041 out.push(if chunk.len() > 2 {
1042 ALPHA[b2 & 63] as char
1043 } else {
1044 '='
1045 });
1046 }
1047 out
1048 }
1049
1050 self.openapi_spec.info.title = title.to_string();
1052 self.openapi_spec.info.version = version.to_string();
1053 if let Some(desc) = description {
1054 self.openapi_spec.info.description = Some(desc.to_string());
1055 }
1056
1057 let path = path.trim_end_matches('/');
1058 let openapi_path = format!("{}/openapi.json", path);
1059
1060 let credentials = format!("{}:{}", username, password);
1062 let encoded = base64_encode(credentials.as_bytes());
1063 let expected_auth = format!("Basic {}", encoded);
1064
1065 let spec_value = self.openapi_spec.to_json();
1067 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
1068 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
1069 "{}".to_string()
1070 });
1071 let openapi_url = openapi_path.clone();
1072 let expected_auth_spec = expected_auth.clone();
1073 let expected_auth_docs = expected_auth;
1074
1075 let spec_handler: crate::handler::BoxedHandler =
1077 std::sync::Arc::new(move |req: crate::Request| {
1078 let json = spec_json.clone();
1079 let expected = expected_auth_spec.clone();
1080 Box::pin(async move {
1081 if !check_basic_auth(&req, &expected) {
1082 return unauthorized_response();
1083 }
1084 http::Response::builder()
1085 .status(http::StatusCode::OK)
1086 .header(http::header::CONTENT_TYPE, "application/json")
1087 .body(crate::response::Body::from(json))
1088 .unwrap_or_else(|e| {
1089 tracing::error!("Failed to build response: {}", e);
1090 http::Response::builder()
1091 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1092 .body(crate::response::Body::from("Internal Server Error"))
1093 .unwrap()
1094 })
1095 })
1096 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1097 });
1098
1099 let docs_handler: crate::handler::BoxedHandler =
1101 std::sync::Arc::new(move |req: crate::Request| {
1102 let url = openapi_url.clone();
1103 let expected = expected_auth_docs.clone();
1104 Box::pin(async move {
1105 if !check_basic_auth(&req, &expected) {
1106 return unauthorized_response();
1107 }
1108 let response = rustapi_openapi::swagger_ui_html(&url);
1109 response.map(crate::response::Body::Full)
1110 })
1111 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1112 });
1113
1114 let mut spec_handlers = HashMap::new();
1116 spec_handlers.insert(http::Method::GET, spec_handler);
1117 let spec_router = MethodRouter::from_boxed(spec_handlers);
1118
1119 let mut docs_handlers = HashMap::new();
1120 docs_handlers.insert(http::Method::GET, docs_handler);
1121 let docs_router = MethodRouter::from_boxed(docs_handlers);
1122
1123 self.route(&openapi_path, spec_router)
1124 .route(path, docs_router)
1125 }
1126
1127 pub fn status_page(self) -> Self {
1129 self.status_page_with_config(crate::status::StatusConfig::default())
1130 }
1131
1132 pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self {
1134 self.status_config = Some(config);
1135 self
1136 }
1137
1138 pub fn health_endpoints(mut self) -> Self {
1143 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1144 if self.health_check.is_none() {
1145 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1146 }
1147 self
1148 }
1149
1150 pub fn health_endpoints_with_config(
1152 mut self,
1153 config: crate::health::HealthEndpointConfig,
1154 ) -> Self {
1155 self.health_endpoint_config = Some(config);
1156 if self.health_check.is_none() {
1157 self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1158 }
1159 self
1160 }
1161
1162 pub fn with_health_check(mut self, health_check: crate::health::HealthCheck) -> Self {
1167 self.health_check = Some(health_check);
1168 if self.health_endpoint_config.is_none() {
1169 self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1170 }
1171 self
1172 }
1173
1174 pub fn production_defaults(self, service_name: impl Into<String>) -> Self {
1181 self.production_defaults_with_config(ProductionDefaultsConfig::new(service_name))
1182 }
1183
1184 pub fn production_defaults_with_config(mut self, config: ProductionDefaultsConfig) -> Self {
1186 if config.enable_request_id {
1187 self = self.layer(crate::middleware::RequestIdLayer::new());
1188 }
1189
1190 if config.enable_tracing {
1191 let mut tracing_layer =
1192 crate::middleware::TracingLayer::with_level(config.tracing_level)
1193 .with_field("service", config.service_name.clone())
1194 .with_field("environment", crate::error::get_environment().to_string());
1195
1196 if let Some(version) = &config.version {
1197 tracing_layer = tracing_layer.with_field("version", version.clone());
1198 }
1199
1200 self = self.layer(tracing_layer);
1201 }
1202
1203 if config.enable_health_endpoints {
1204 if self.health_check.is_none() {
1205 let mut builder = crate::health::HealthCheckBuilder::default();
1206 if let Some(version) = &config.version {
1207 builder = builder.version(version.clone());
1208 }
1209 self.health_check = Some(builder.build());
1210 }
1211
1212 if self.health_endpoint_config.is_none() {
1213 self.health_endpoint_config =
1214 Some(config.health_endpoint_config.unwrap_or_default());
1215 }
1216 }
1217
1218 self
1219 }
1220
1221 fn print_hot_reload_banner(&self, addr: &str) {
1223 if !self.hot_reload {
1224 return;
1225 }
1226
1227 std::env::set_var("RUSTAPI_HOT_RELOAD", "1");
1229
1230 let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD")
1231 .map(|v| v == "1")
1232 .unwrap_or(false);
1233
1234 tracing::info!("🔄 Hot-reload mode enabled");
1235
1236 if is_under_watcher {
1237 tracing::info!(" File watcher active — changes will trigger rebuild + restart");
1238 } else {
1239 tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload");
1240 }
1241
1242 tracing::info!(" Listening on http://{addr}");
1243 }
1244
1245 fn apply_health_endpoints(&mut self) {
1247 if let Some(config) = &self.health_endpoint_config {
1248 use crate::router::get;
1249
1250 let health_check = self
1251 .health_check
1252 .clone()
1253 .unwrap_or_else(|| crate::health::HealthCheckBuilder::default().build());
1254
1255 let health_path = config.health_path.clone();
1256 let readiness_path = config.readiness_path.clone();
1257 let liveness_path = config.liveness_path.clone();
1258
1259 let health_handler = {
1260 let health_check = health_check.clone();
1261 move || {
1262 let health_check = health_check.clone();
1263 async move { crate::health::health_response(health_check).await }
1264 }
1265 };
1266
1267 let readiness_handler = {
1268 let health_check = health_check.clone();
1269 move || {
1270 let health_check = health_check.clone();
1271 async move { crate::health::readiness_response(health_check).await }
1272 }
1273 };
1274
1275 let liveness_handler = || async { crate::health::liveness_response().await };
1276
1277 let router = std::mem::take(&mut self.router);
1278 self.router = router
1279 .route(&health_path, get(health_handler))
1280 .route(&readiness_path, get(readiness_handler))
1281 .route(&liveness_path, get(liveness_handler));
1282 }
1283 }
1284
1285 fn apply_status_page(&mut self) {
1286 if let Some(config) = &self.status_config {
1287 let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new());
1288
1289 self.layers
1291 .push(Box::new(crate::status::StatusLayer::new(monitor.clone())));
1292
1293 use crate::router::MethodRouter;
1295 use std::collections::HashMap;
1296
1297 let monitor = monitor.clone();
1298 let config = config.clone();
1299 let path = config.path.clone(); let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| {
1302 let monitor = monitor.clone();
1303 let config = config.clone();
1304 Box::pin(async move {
1305 crate::status::status_handler(monitor, config)
1306 .await
1307 .into_response()
1308 })
1309 });
1310
1311 let mut handlers = HashMap::new();
1312 handlers.insert(http::Method::GET, handler);
1313 let method_router = MethodRouter::from_boxed(handlers);
1314
1315 let router = std::mem::take(&mut self.router);
1317 self.router = router.route(&path, method_router);
1318 }
1319 }
1320
1321 #[cfg(feature = "dashboard")]
1322 fn apply_dashboard(&mut self) {
1323 use crate::dashboard::{DashboardMetrics, RouteInventoryItem};
1324 use crate::handler::BoxedHandler;
1325 use crate::response::Body;
1326 use crate::router::MethodRouter;
1327 use std::collections::HashMap;
1328 use std::sync::Arc;
1329
1330 let mut config = match self.dashboard_config.take() {
1331 Some(c) => c,
1332 None => return,
1333 };
1334 config.normalize_paths();
1335
1336 let mut inventory: Vec<RouteInventoryItem> = self
1340 .router
1341 .registered_routes()
1342 .values()
1343 .map(|info| {
1344 let methods: Vec<String> = info.methods.iter().map(|m| m.to_string()).collect();
1345 let health_eligible = self
1346 .health_endpoint_config
1347 .as_ref()
1348 .map(|health| {
1349 info.path == health.health_path
1350 || info.path == health.readiness_path
1351 || info.path == health.liveness_path
1352 })
1353 .unwrap_or(false);
1354
1355 RouteInventoryItem::new(info.path.clone(), methods)
1356 .with_tags(openapi_tags_for_route(
1357 &self.openapi_spec,
1358 &info.path,
1359 &info.methods,
1360 ))
1361 .with_feature_gates(infer_route_feature_gates(&info.path))
1362 .health_eligible(health_eligible)
1363 .replay_eligible(is_dashboard_replay_eligible(&info.path, health_eligible))
1364 })
1365 .collect();
1366 inventory.sort_by(|a, b| a.path.cmp(&b.path));
1367
1368 let metrics = Arc::new(DashboardMetrics::new_with_replay_admin_path(
1369 inventory,
1370 config.replay_api_path.clone(),
1371 ));
1372
1373 let router = std::mem::take(&mut self.router);
1375 self.router = router.state(Arc::clone(&metrics));
1376
1377 let prefix = config.path.trim_end_matches('/').to_owned();
1379
1380 fn not_found() -> crate::response::Response {
1381 http::Response::builder()
1382 .status(404)
1383 .body(Body::Full(http_body_util::Full::new(bytes::Bytes::from(
1384 "Not Found",
1385 ))))
1386 .unwrap()
1387 }
1388
1389 {
1391 let metrics_c = Arc::clone(&metrics);
1392 let config_c = config.clone();
1393 let handler: BoxedHandler = Arc::new(move |req| {
1394 let metrics = Arc::clone(&metrics_c);
1395 let cfg = config_c.clone();
1396 Box::pin(async move {
1397 let headers = req.headers().clone();
1398 let method = req.method().to_string();
1399 let path = req.uri().path().to_owned();
1400 crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg)
1401 .await
1402 .unwrap_or_else(not_found)
1403 })
1404 });
1405 let mut h = HashMap::new();
1406 h.insert(http::Method::GET, handler);
1407 let router = std::mem::take(&mut self.router);
1408 self.router = router.route(&prefix, MethodRouter::from_boxed(h));
1409 }
1410
1411 {
1413 let metrics_c = Arc::clone(&metrics);
1414 let config_c = config.clone();
1415 let wildcard_path = format!("{}/*path", prefix);
1416 let handler: BoxedHandler = Arc::new(move |req| {
1417 let metrics = Arc::clone(&metrics_c);
1418 let cfg = config_c.clone();
1419 Box::pin(async move {
1420 let headers = req.headers().clone();
1421 let method = req.method().to_string();
1422 let path = req.uri().path().to_owned();
1423 crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg)
1424 .await
1425 .unwrap_or_else(not_found)
1426 })
1427 });
1428 let mut h = HashMap::new();
1429 h.insert(http::Method::GET, handler);
1430 let router = std::mem::take(&mut self.router);
1431 self.router = router.route(&wildcard_path, MethodRouter::from_boxed(h));
1432 }
1433 }
1434
1435 #[cfg(feature = "dashboard")]
1455 pub fn dashboard(mut self, config: crate::dashboard::DashboardConfig) -> Self {
1456 self.dashboard_config = Some(config);
1457 self
1458 }
1459
1460 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1471 self.print_hot_reload_banner(addr);
1473
1474 self.apply_health_endpoints();
1476
1477 self.apply_status_page();
1479
1480 #[cfg(feature = "dashboard")]
1482 self.apply_dashboard();
1483
1484 if let Some(limit) = self.body_limit {
1486 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1488 }
1489
1490 for hook in self.lifecycle_hooks.on_start {
1492 hook().await;
1493 }
1494
1495 let server = Server::new(self.router, self.layers, self.interceptors);
1496 server.run(addr).await
1497 }
1498
1499 pub async fn run_with_shutdown<F>(
1501 mut self,
1502 addr: impl AsRef<str>,
1503 signal: F,
1504 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1505 where
1506 F: std::future::Future<Output = ()> + Send + 'static,
1507 {
1508 self.print_hot_reload_banner(addr.as_ref());
1510
1511 self.apply_health_endpoints();
1513
1514 self.apply_status_page();
1516
1517 #[cfg(feature = "dashboard")]
1519 self.apply_dashboard();
1520
1521 if let Some(limit) = self.body_limit {
1522 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1523 }
1524
1525 for hook in self.lifecycle_hooks.on_start {
1527 hook().await;
1528 }
1529
1530 let shutdown_hooks = self.lifecycle_hooks.on_shutdown;
1532 let wrapped_signal = async move {
1533 signal.await;
1534 for hook in shutdown_hooks {
1536 hook().await;
1537 }
1538 };
1539
1540 let server = Server::new(self.router, self.layers, self.interceptors);
1541 server
1542 .run_with_shutdown(addr.as_ref(), wrapped_signal)
1543 .await
1544 }
1545
1546 pub fn into_router(self) -> Router {
1548 self.router
1549 }
1550
1551 pub fn layers(&self) -> &LayerStack {
1553 &self.layers
1554 }
1555
1556 pub fn interceptors(&self) -> &InterceptorChain {
1558 &self.interceptors
1559 }
1560
1561 #[cfg(feature = "http3")]
1575 pub async fn run_http3(
1576 mut self,
1577 config: crate::http3::Http3Config,
1578 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1579 use std::sync::Arc;
1580
1581 self.apply_health_endpoints();
1583
1584 self.apply_status_page();
1586
1587 if let Some(limit) = self.body_limit {
1589 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1590 }
1591
1592 let server = crate::http3::Http3Server::new(
1593 &config,
1594 Arc::new(self.router),
1595 Arc::new(self.layers),
1596 Arc::new(self.interceptors),
1597 )
1598 .await?;
1599
1600 server.run().await
1601 }
1602
1603 #[cfg(feature = "http3-dev")]
1617 pub async fn run_http3_dev(
1618 mut self,
1619 addr: &str,
1620 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1621 use std::sync::Arc;
1622
1623 self.apply_health_endpoints();
1625
1626 self.apply_status_page();
1628
1629 if let Some(limit) = self.body_limit {
1631 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1632 }
1633
1634 let server = crate::http3::Http3Server::new_with_self_signed(
1635 addr,
1636 Arc::new(self.router),
1637 Arc::new(self.layers),
1638 Arc::new(self.interceptors),
1639 )
1640 .await?;
1641
1642 server.run().await
1643 }
1644
1645 #[cfg(feature = "http3")]
1656 pub fn with_http3(mut self, cert_path: impl Into<String>, key_path: impl Into<String>) -> Self {
1657 self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path));
1658 self
1659 }
1660
1661 #[cfg(feature = "http3")]
1676 pub async fn run_dual_stack(
1677 mut self,
1678 http_addr: &str,
1679 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1680 use std::sync::Arc;
1681
1682 let mut config = self
1683 .http3_config
1684 .take()
1685 .ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
1686
1687 let http_socket: std::net::SocketAddr = http_addr.parse()?;
1688 config.bind_addr = if http_socket.ip().is_ipv6() {
1689 format!("[{}]", http_socket.ip())
1690 } else {
1691 http_socket.ip().to_string()
1692 };
1693 config.port = http_socket.port();
1694 let http_addr = http_socket.to_string();
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 router = Arc::new(self.router);
1708 let layers = Arc::new(self.layers);
1709 let interceptors = Arc::new(self.interceptors);
1710
1711 let http1_server =
1712 Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1713 let http3_server =
1714 crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1715
1716 tracing::info!(
1717 http1_addr = %http_addr,
1718 http3_addr = %config.socket_addr(),
1719 "Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1720 );
1721
1722 tokio::try_join!(
1723 http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1724 http3_server.run_with_shutdown(std::future::pending::<()>()),
1725 )?;
1726
1727 Ok(())
1728 }
1729}
1730
1731#[cfg(feature = "dashboard")]
1732fn openapi_tags_for_route(
1733 spec: &rustapi_openapi::OpenApiSpec,
1734 path: &str,
1735 methods: &[http::Method],
1736) -> Vec<String> {
1737 let Some(path_item) = spec.paths.get(path) else {
1738 return Vec::new();
1739 };
1740
1741 let mut tags = BTreeSet::new();
1742 for method in methods {
1743 if let Some(operation) = operation_for_method(path_item, method) {
1744 tags.extend(operation.tags.iter().cloned());
1745 }
1746 }
1747
1748 tags.into_iter().collect()
1749}
1750
1751#[cfg(feature = "dashboard")]
1752fn operation_for_method<'a>(
1753 path_item: &'a rustapi_openapi::PathItem,
1754 method: &http::Method,
1755) -> Option<&'a rustapi_openapi::Operation> {
1756 match *method {
1757 http::Method::GET => path_item.get.as_ref(),
1758 http::Method::POST => path_item.post.as_ref(),
1759 http::Method::PUT => path_item.put.as_ref(),
1760 http::Method::PATCH => path_item.patch.as_ref(),
1761 http::Method::DELETE => path_item.delete.as_ref(),
1762 http::Method::HEAD => path_item.head.as_ref(),
1763 http::Method::OPTIONS => path_item.options.as_ref(),
1764 http::Method::TRACE => path_item.trace.as_ref(),
1765 _ => None,
1766 }
1767}
1768
1769#[cfg(feature = "dashboard")]
1770fn infer_route_feature_gates(path: &str) -> Vec<String> {
1771 if path.contains("openapi") || path.contains("docs") {
1772 vec!["core-openapi".to_string()]
1773 } else if path.starts_with("/__rustapi/replays") {
1774 vec!["extras-replay".to_string()]
1775 } else {
1776 Vec::new()
1777 }
1778}
1779
1780#[cfg(feature = "dashboard")]
1781fn is_dashboard_replay_eligible(path: &str, health_eligible: bool) -> bool {
1782 !health_eligible && !path.starts_with("/__rustapi/")
1783}
1784
1785fn add_path_params_to_operation(
1786 path: &str,
1787 op: &mut rustapi_openapi::Operation,
1788 param_schemas: &BTreeMap<String, String>,
1789) {
1790 let mut params: Vec<String> = Vec::new();
1791 let mut in_brace = false;
1792 let mut current = String::new();
1793
1794 for ch in path.chars() {
1795 match ch {
1796 '{' => {
1797 in_brace = true;
1798 current.clear();
1799 }
1800 '}' => {
1801 if in_brace {
1802 in_brace = false;
1803 if !current.is_empty() {
1804 params.push(current.clone());
1805 }
1806 }
1807 }
1808 _ => {
1809 if in_brace {
1810 current.push(ch);
1811 }
1812 }
1813 }
1814 }
1815
1816 if params.is_empty() {
1817 return;
1818 }
1819
1820 let op_params = &mut op.parameters;
1821
1822 for name in params {
1823 let already = op_params
1824 .iter()
1825 .any(|p| p.location == "path" && p.name == name);
1826 if already {
1827 continue;
1828 }
1829
1830 let schema = if let Some(schema_type) = param_schemas.get(&name) {
1832 schema_type_to_openapi_schema(schema_type)
1833 } else {
1834 infer_path_param_schema(&name)
1835 };
1836
1837 op_params.push(rustapi_openapi::Parameter {
1838 name,
1839 location: "path".to_string(),
1840 required: true,
1841 description: None,
1842 deprecated: None,
1843 schema: Some(schema),
1844 });
1845 }
1846}
1847
1848fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef {
1850 match schema_type.to_lowercase().as_str() {
1851 "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1852 "type": "string",
1853 "format": "uuid"
1854 })),
1855 "integer" | "int" | "int64" | "i64" => {
1856 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1857 "type": "integer",
1858 "format": "int64"
1859 }))
1860 }
1861 "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1862 "type": "integer",
1863 "format": "int32"
1864 })),
1865 "number" | "float" | "f64" | "f32" => {
1866 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1867 "type": "number"
1868 }))
1869 }
1870 "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1871 "type": "boolean"
1872 })),
1873 _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1874 "type": "string"
1875 })),
1876 }
1877}
1878
1879fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
1888 let lower = name.to_lowercase();
1889
1890 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
1892
1893 if is_uuid {
1894 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1895 "type": "string",
1896 "format": "uuid"
1897 }));
1898 }
1899
1900 let is_integer = lower == "page"
1903 || lower == "limit"
1904 || lower == "offset"
1905 || lower == "count"
1906 || lower.ends_with("_count")
1907 || lower.ends_with("_num")
1908 || lower == "year"
1909 || lower == "month"
1910 || lower == "day"
1911 || lower == "index"
1912 || lower == "position";
1913
1914 if is_integer {
1915 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1916 "type": "integer",
1917 "format": "int64"
1918 }))
1919 } else {
1920 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
1921 }
1922}
1923
1924fn normalize_prefix_for_openapi(prefix: &str) -> String {
1931 if prefix.is_empty() {
1933 return "/".to_string();
1934 }
1935
1936 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
1938
1939 if segments.is_empty() {
1941 return "/".to_string();
1942 }
1943
1944 let mut result = String::with_capacity(prefix.len() + 1);
1946 for segment in segments {
1947 result.push('/');
1948 result.push_str(segment);
1949 }
1950
1951 result
1952}
1953
1954impl Default for RustApi {
1955 fn default() -> Self {
1956 Self::new()
1957 }
1958}
1959
1960#[cfg(feature = "swagger-ui")]
1962fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1963 req.headers()
1964 .get(http::header::AUTHORIZATION)
1965 .and_then(|v| v.to_str().ok())
1966 .map(|auth| auth == expected)
1967 .unwrap_or(false)
1968}
1969
1970#[cfg(feature = "swagger-ui")]
1972fn unauthorized_response() -> crate::Response {
1973 http::Response::builder()
1974 .status(http::StatusCode::UNAUTHORIZED)
1975 .header(
1976 http::header::WWW_AUTHENTICATE,
1977 "Basic realm=\"API Documentation\"",
1978 )
1979 .header(http::header::CONTENT_TYPE, "text/plain")
1980 .body(crate::response::Body::from("Unauthorized"))
1981 .unwrap()
1982}
1983
1984pub struct RustApiConfig {
1986 docs_path: Option<String>,
1987 docs_enabled: bool,
1988 api_title: String,
1989 api_version: String,
1990 api_description: Option<String>,
1991 body_limit: Option<usize>,
1992 layers: LayerStack,
1993}
1994
1995impl Default for RustApiConfig {
1996 fn default() -> Self {
1997 Self::new()
1998 }
1999}
2000
2001impl RustApiConfig {
2002 pub fn new() -> Self {
2003 Self {
2004 docs_path: Some("/docs".to_string()),
2005 docs_enabled: true,
2006 api_title: "RustAPI".to_string(),
2007 api_version: "1.0.0".to_string(),
2008 api_description: None,
2009 body_limit: None,
2010 layers: LayerStack::new(),
2011 }
2012 }
2013
2014 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
2016 self.docs_path = Some(path.into());
2017 self
2018 }
2019
2020 pub fn docs_enabled(mut self, enabled: bool) -> Self {
2022 self.docs_enabled = enabled;
2023 self
2024 }
2025
2026 pub fn openapi_info(
2028 mut self,
2029 title: impl Into<String>,
2030 version: impl Into<String>,
2031 description: Option<impl Into<String>>,
2032 ) -> Self {
2033 self.api_title = title.into();
2034 self.api_version = version.into();
2035 self.api_description = description.map(|d| d.into());
2036 self
2037 }
2038
2039 pub fn body_limit(mut self, limit: usize) -> Self {
2041 self.body_limit = Some(limit);
2042 self
2043 }
2044
2045 pub fn layer<L>(mut self, layer: L) -> Self
2047 where
2048 L: MiddlewareLayer,
2049 {
2050 self.layers.push(Box::new(layer));
2051 self
2052 }
2053
2054 pub fn build(self) -> RustApi {
2056 let mut app = RustApi::new().mount_auto_routes_grouped();
2057
2058 if let Some(limit) = self.body_limit {
2060 app = app.body_limit(limit);
2061 }
2062
2063 app = app.openapi_info(
2064 &self.api_title,
2065 &self.api_version,
2066 self.api_description.as_deref(),
2067 );
2068
2069 #[cfg(feature = "swagger-ui")]
2070 if self.docs_enabled {
2071 if let Some(path) = self.docs_path {
2072 app = app.docs(&path);
2073 }
2074 }
2075
2076 app.layers.extend(self.layers);
2079
2080 app
2081 }
2082
2083 pub async fn run(
2085 self,
2086 addr: impl AsRef<str>,
2087 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2088 self.build().run(addr.as_ref()).await
2089 }
2090}
2091
2092#[cfg(test)]
2093mod tests {
2094 use super::RustApi;
2095 use crate::extract::{FromRequestParts, State};
2096 use crate::path_params::PathParams;
2097 use crate::request::Request;
2098 use crate::router::{get, post, Router};
2099 use bytes::Bytes;
2100 use http::Method;
2101 use proptest::prelude::*;
2102
2103 #[test]
2104 fn state_is_available_via_extractor() {
2105 let app = RustApi::new().state(123u32);
2106 let router = app.into_router();
2107
2108 let req = http::Request::builder()
2109 .method(Method::GET)
2110 .uri("/test")
2111 .body(())
2112 .unwrap();
2113 let (parts, _) = req.into_parts();
2114
2115 let request = Request::new(
2116 parts,
2117 crate::request::BodyVariant::Buffered(Bytes::new()),
2118 router.state_ref(),
2119 PathParams::new(),
2120 );
2121 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
2122 assert_eq!(value, 123u32);
2123 }
2124
2125 #[test]
2126 fn test_path_param_type_inference_integer() {
2127 use super::infer_path_param_schema;
2128
2129 let int_params = [
2131 "page",
2132 "limit",
2133 "offset",
2134 "count",
2135 "item_count",
2136 "year",
2137 "month",
2138 "day",
2139 "index",
2140 "position",
2141 ];
2142
2143 for name in int_params {
2144 let schema = infer_path_param_schema(name);
2145 match schema {
2146 rustapi_openapi::SchemaRef::Inline(v) => {
2147 assert_eq!(
2148 v.get("type").and_then(|v| v.as_str()),
2149 Some("integer"),
2150 "Expected '{}' to be inferred as integer",
2151 name
2152 );
2153 }
2154 _ => panic!("Expected inline schema for '{}'", name),
2155 }
2156 }
2157 }
2158
2159 #[test]
2160 fn test_path_param_type_inference_uuid() {
2161 use super::infer_path_param_schema;
2162
2163 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
2165
2166 for name in uuid_params {
2167 let schema = infer_path_param_schema(name);
2168 match schema {
2169 rustapi_openapi::SchemaRef::Inline(v) => {
2170 assert_eq!(
2171 v.get("type").and_then(|v| v.as_str()),
2172 Some("string"),
2173 "Expected '{}' to be inferred as string",
2174 name
2175 );
2176 assert_eq!(
2177 v.get("format").and_then(|v| v.as_str()),
2178 Some("uuid"),
2179 "Expected '{}' to have uuid format",
2180 name
2181 );
2182 }
2183 _ => panic!("Expected inline schema for '{}'", name),
2184 }
2185 }
2186 }
2187
2188 #[test]
2189 fn test_path_param_type_inference_string() {
2190 use super::infer_path_param_schema;
2191
2192 let string_params = [
2194 "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId",
2195 ];
2196
2197 for name in string_params {
2198 let schema = infer_path_param_schema(name);
2199 match schema {
2200 rustapi_openapi::SchemaRef::Inline(v) => {
2201 assert_eq!(
2202 v.get("type").and_then(|v| v.as_str()),
2203 Some("string"),
2204 "Expected '{}' to be inferred as string",
2205 name
2206 );
2207 assert!(
2208 v.get("format").is_none()
2209 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
2210 "Expected '{}' to NOT have uuid format",
2211 name
2212 );
2213 }
2214 _ => panic!("Expected inline schema for '{}'", name),
2215 }
2216 }
2217 }
2218
2219 #[test]
2220 fn test_schema_type_to_openapi_schema() {
2221 use super::schema_type_to_openapi_schema;
2222
2223 let uuid_schema = schema_type_to_openapi_schema("uuid");
2225 match uuid_schema {
2226 rustapi_openapi::SchemaRef::Inline(v) => {
2227 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2228 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid"));
2229 }
2230 _ => panic!("Expected inline schema for uuid"),
2231 }
2232
2233 for schema_type in ["integer", "int", "int64", "i64"] {
2235 let schema = schema_type_to_openapi_schema(schema_type);
2236 match schema {
2237 rustapi_openapi::SchemaRef::Inline(v) => {
2238 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
2239 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64"));
2240 }
2241 _ => panic!("Expected inline schema for {}", schema_type),
2242 }
2243 }
2244
2245 let int32_schema = schema_type_to_openapi_schema("int32");
2247 match int32_schema {
2248 rustapi_openapi::SchemaRef::Inline(v) => {
2249 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
2250 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32"));
2251 }
2252 _ => panic!("Expected inline schema for int32"),
2253 }
2254
2255 for schema_type in ["number", "float"] {
2257 let schema = schema_type_to_openapi_schema(schema_type);
2258 match schema {
2259 rustapi_openapi::SchemaRef::Inline(v) => {
2260 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number"));
2261 }
2262 _ => panic!("Expected inline schema for {}", schema_type),
2263 }
2264 }
2265
2266 for schema_type in ["boolean", "bool"] {
2268 let schema = schema_type_to_openapi_schema(schema_type);
2269 match schema {
2270 rustapi_openapi::SchemaRef::Inline(v) => {
2271 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean"));
2272 }
2273 _ => panic!("Expected inline schema for {}", schema_type),
2274 }
2275 }
2276
2277 let string_schema = schema_type_to_openapi_schema("string");
2279 match string_schema {
2280 rustapi_openapi::SchemaRef::Inline(v) => {
2281 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2282 }
2283 _ => panic!("Expected inline schema for string"),
2284 }
2285 }
2286
2287 proptest! {
2294 #![proptest_config(ProptestConfig::with_cases(100))]
2295
2296 #[test]
2301 fn prop_nested_routes_in_openapi_spec(
2302 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2304 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2306 has_param in any::<bool>(),
2307 ) {
2308 async fn handler() -> &'static str { "handler" }
2309
2310 let prefix = format!("/{}", prefix_segments.join("/"));
2312
2313 let mut route_path = format!("/{}", route_segments.join("/"));
2315 if has_param {
2316 route_path.push_str("/{id}");
2317 }
2318
2319 let nested_router = Router::new().route(&route_path, get(handler));
2321 let app = RustApi::new().nest(&prefix, nested_router);
2322
2323 let expected_openapi_path = format!("{}{}", prefix, route_path);
2325
2326 let spec = app.openapi_spec();
2328
2329 prop_assert!(
2331 spec.paths.contains_key(&expected_openapi_path),
2332 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2333 expected_openapi_path,
2334 spec.paths.keys().collect::<Vec<_>>()
2335 );
2336
2337 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2339 prop_assert!(
2340 path_item.get.is_some(),
2341 "GET operation should exist for path '{}'",
2342 expected_openapi_path
2343 );
2344 }
2345
2346 #[test]
2351 fn prop_multiple_methods_preserved_in_openapi(
2352 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2353 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2354 ) {
2355 async fn get_handler() -> &'static str { "get" }
2356 async fn post_handler() -> &'static str { "post" }
2357
2358 let prefix = format!("/{}", prefix_segments.join("/"));
2360 let route_path = format!("/{}", route_segments.join("/"));
2361
2362 let get_route_path = format!("{}/get", route_path);
2365 let post_route_path = format!("{}/post", route_path);
2366 let nested_router = Router::new()
2367 .route(&get_route_path, get(get_handler))
2368 .route(&post_route_path, post(post_handler));
2369 let app = RustApi::new().nest(&prefix, nested_router);
2370
2371 let expected_get_path = format!("{}{}", prefix, get_route_path);
2373 let expected_post_path = format!("{}{}", prefix, post_route_path);
2374
2375 let spec = app.openapi_spec();
2377
2378 prop_assert!(
2380 spec.paths.contains_key(&expected_get_path),
2381 "Expected OpenAPI path '{}' not found",
2382 expected_get_path
2383 );
2384 prop_assert!(
2385 spec.paths.contains_key(&expected_post_path),
2386 "Expected OpenAPI path '{}' not found",
2387 expected_post_path
2388 );
2389
2390 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
2392 prop_assert!(
2393 get_path_item.get.is_some(),
2394 "GET operation should exist for path '{}'",
2395 expected_get_path
2396 );
2397
2398 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
2400 prop_assert!(
2401 post_path_item.post.is_some(),
2402 "POST operation should exist for path '{}'",
2403 expected_post_path
2404 );
2405 }
2406
2407 #[test]
2412 fn prop_path_params_in_openapi_after_nesting(
2413 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2414 param_name in "[a-z][a-z0-9]{0,5}",
2415 ) {
2416 async fn handler() -> &'static str { "handler" }
2417
2418 let prefix = format!("/{}", prefix_segments.join("/"));
2420 let route_path = format!("/{{{}}}", param_name);
2421
2422 let nested_router = Router::new().route(&route_path, get(handler));
2424 let app = RustApi::new().nest(&prefix, nested_router);
2425
2426 let expected_openapi_path = format!("{}{}", prefix, route_path);
2428
2429 let spec = app.openapi_spec();
2431
2432 prop_assert!(
2434 spec.paths.contains_key(&expected_openapi_path),
2435 "Expected OpenAPI path '{}' not found",
2436 expected_openapi_path
2437 );
2438
2439 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2441 let get_op = path_item.get.as_ref().unwrap();
2442
2443 prop_assert!(
2444 !get_op.parameters.is_empty(),
2445 "Operation should have parameters for path '{}'",
2446 expected_openapi_path
2447 );
2448
2449 let params = &get_op.parameters;
2450 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
2451 prop_assert!(
2452 has_param,
2453 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
2454 param_name,
2455 params.iter().map(|p| &p.name).collect::<Vec<_>>()
2456 );
2457 }
2458 }
2459
2460 proptest! {
2468 #![proptest_config(ProptestConfig::with_cases(100))]
2469
2470 #[test]
2475 fn prop_rustapi_nest_delegates_to_router_nest(
2476 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2477 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2478 has_param in any::<bool>(),
2479 ) {
2480 async fn handler() -> &'static str { "handler" }
2481
2482 let prefix = format!("/{}", prefix_segments.join("/"));
2484
2485 let mut route_path = format!("/{}", route_segments.join("/"));
2487 if has_param {
2488 route_path.push_str("/{id}");
2489 }
2490
2491 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2493 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2494
2495 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2497 let rustapi_router = rustapi_app.into_router();
2498
2499 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2501
2502 let rustapi_routes = rustapi_router.registered_routes();
2504 let router_routes = router_app.registered_routes();
2505
2506 prop_assert_eq!(
2507 rustapi_routes.len(),
2508 router_routes.len(),
2509 "RustApi and Router should have same number of routes"
2510 );
2511
2512 for (path, info) in router_routes {
2514 prop_assert!(
2515 rustapi_routes.contains_key(path),
2516 "Route '{}' from Router should exist in RustApi routes",
2517 path
2518 );
2519
2520 let rustapi_info = rustapi_routes.get(path).unwrap();
2521 prop_assert_eq!(
2522 &info.path, &rustapi_info.path,
2523 "Display paths should match for route '{}'",
2524 path
2525 );
2526 prop_assert_eq!(
2527 info.methods.len(), rustapi_info.methods.len(),
2528 "Method count should match for route '{}'",
2529 path
2530 );
2531 }
2532 }
2533
2534 #[test]
2539 fn prop_rustapi_nest_includes_routes_in_openapi(
2540 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2541 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2542 has_param in any::<bool>(),
2543 ) {
2544 async fn handler() -> &'static str { "handler" }
2545
2546 let prefix = format!("/{}", prefix_segments.join("/"));
2548
2549 let mut route_path = format!("/{}", route_segments.join("/"));
2551 if has_param {
2552 route_path.push_str("/{id}");
2553 }
2554
2555 let nested_router = Router::new().route(&route_path, get(handler));
2557 let app = RustApi::new().nest(&prefix, nested_router);
2558
2559 let expected_openapi_path = format!("{}{}", prefix, route_path);
2561
2562 let spec = app.openapi_spec();
2564
2565 prop_assert!(
2567 spec.paths.contains_key(&expected_openapi_path),
2568 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2569 expected_openapi_path,
2570 spec.paths.keys().collect::<Vec<_>>()
2571 );
2572
2573 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2575 prop_assert!(
2576 path_item.get.is_some(),
2577 "GET operation should exist for path '{}'",
2578 expected_openapi_path
2579 );
2580 }
2581
2582 #[test]
2587 fn prop_rustapi_nest_route_matching_identical(
2588 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2589 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2590 param_value in "[a-z0-9]{1,10}",
2591 ) {
2592 use crate::router::RouteMatch;
2593
2594 async fn handler() -> &'static str { "handler" }
2595
2596 let prefix = format!("/{}", prefix_segments.join("/"));
2598 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
2599
2600 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2602 let nested_router_for_router = Router::new().route(&route_path, get(handler));
2603
2604 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2606 let rustapi_router = rustapi_app.into_router();
2607 let router_app = Router::new().nest(&prefix, nested_router_for_router);
2608
2609 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
2611
2612 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
2614 let router_match = router_app.match_route(&full_path, &Method::GET);
2615
2616 match (rustapi_match, router_match) {
2618 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
2619 prop_assert_eq!(
2620 rustapi_params.len(),
2621 router_params.len(),
2622 "Parameter count should match"
2623 );
2624 for (key, value) in &router_params {
2625 prop_assert!(
2626 rustapi_params.contains_key(key),
2627 "RustApi should have parameter '{}'",
2628 key
2629 );
2630 prop_assert_eq!(
2631 rustapi_params.get(key).unwrap(),
2632 value,
2633 "Parameter '{}' value should match",
2634 key
2635 );
2636 }
2637 }
2638 (rustapi_result, router_result) => {
2639 prop_assert!(
2640 false,
2641 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
2642 match rustapi_result {
2643 RouteMatch::Found { .. } => "Found",
2644 RouteMatch::NotFound => "NotFound",
2645 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2646 },
2647 match router_result {
2648 RouteMatch::Found { .. } => "Found",
2649 RouteMatch::NotFound => "NotFound",
2650 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2651 }
2652 );
2653 }
2654 }
2655 }
2656 }
2657
2658 #[test]
2660 fn test_openapi_operations_propagated_during_nesting() {
2661 async fn list_users() -> &'static str {
2662 "list users"
2663 }
2664 async fn get_user() -> &'static str {
2665 "get user"
2666 }
2667 async fn create_user() -> &'static str {
2668 "create user"
2669 }
2670
2671 let users_router = Router::new()
2674 .route("/", get(list_users))
2675 .route("/create", post(create_user))
2676 .route("/{id}", get(get_user));
2677
2678 let app = RustApi::new().nest("/api/v1/users", users_router);
2680
2681 let spec = app.openapi_spec();
2682
2683 assert!(
2685 spec.paths.contains_key("/api/v1/users"),
2686 "Should have /api/v1/users path"
2687 );
2688 let users_path = spec.paths.get("/api/v1/users").unwrap();
2689 assert!(users_path.get.is_some(), "Should have GET operation");
2690
2691 assert!(
2693 spec.paths.contains_key("/api/v1/users/create"),
2694 "Should have /api/v1/users/create path"
2695 );
2696 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
2697 assert!(create_path.post.is_some(), "Should have POST operation");
2698
2699 assert!(
2701 spec.paths.contains_key("/api/v1/users/{id}"),
2702 "Should have /api/v1/users/{{id}} path"
2703 );
2704 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
2705 assert!(
2706 user_path.get.is_some(),
2707 "Should have GET operation for user by id"
2708 );
2709
2710 let get_user_op = user_path.get.as_ref().unwrap();
2712 assert!(!get_user_op.parameters.is_empty(), "Should have parameters");
2713 let params = &get_user_op.parameters;
2714 assert!(
2715 params
2716 .iter()
2717 .any(|p| p.name == "id" && p.location == "path"),
2718 "Should have 'id' path parameter"
2719 );
2720 }
2721
2722 #[test]
2724 fn test_openapi_spec_empty_without_routes() {
2725 let app = RustApi::new();
2726 let spec = app.openapi_spec();
2727
2728 assert!(
2730 spec.paths.is_empty(),
2731 "OpenAPI spec should have no paths without routes"
2732 );
2733 }
2734
2735 #[test]
2740 fn test_rustapi_nest_delegates_to_router_nest() {
2741 use crate::router::RouteMatch;
2742
2743 async fn list_users() -> &'static str {
2744 "list users"
2745 }
2746 async fn get_user() -> &'static str {
2747 "get user"
2748 }
2749 async fn create_user() -> &'static str {
2750 "create user"
2751 }
2752
2753 let users_router = Router::new()
2755 .route("/", get(list_users))
2756 .route("/create", post(create_user))
2757 .route("/{id}", get(get_user));
2758
2759 let app = RustApi::new().nest("/api/v1/users", users_router);
2761 let router = app.into_router();
2762
2763 let routes = router.registered_routes();
2765 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
2766
2767 assert!(
2769 routes.contains_key("/api/v1/users"),
2770 "Should have /api/v1/users route"
2771 );
2772 assert!(
2773 routes.contains_key("/api/v1/users/create"),
2774 "Should have /api/v1/users/create route"
2775 );
2776 assert!(
2777 routes.contains_key("/api/v1/users/:id"),
2778 "Should have /api/v1/users/:id route"
2779 );
2780
2781 match router.match_route("/api/v1/users", &Method::GET) {
2783 RouteMatch::Found { params, .. } => {
2784 assert!(params.is_empty(), "Root route should have no params");
2785 }
2786 _ => panic!("GET /api/v1/users should be found"),
2787 }
2788
2789 match router.match_route("/api/v1/users/create", &Method::POST) {
2790 RouteMatch::Found { params, .. } => {
2791 assert!(params.is_empty(), "Create route should have no params");
2792 }
2793 _ => panic!("POST /api/v1/users/create should be found"),
2794 }
2795
2796 match router.match_route("/api/v1/users/123", &Method::GET) {
2797 RouteMatch::Found { params, .. } => {
2798 assert_eq!(
2799 params.get("id"),
2800 Some(&"123".to_string()),
2801 "Should extract id param"
2802 );
2803 }
2804 _ => panic!("GET /api/v1/users/123 should be found"),
2805 }
2806
2807 match router.match_route("/api/v1/users", &Method::DELETE) {
2809 RouteMatch::MethodNotAllowed { allowed } => {
2810 assert!(allowed.contains(&Method::GET), "Should allow GET");
2811 }
2812 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
2813 }
2814 }
2815
2816 #[test]
2821 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
2822 async fn list_items() -> &'static str {
2823 "list items"
2824 }
2825 async fn get_item() -> &'static str {
2826 "get item"
2827 }
2828
2829 let items_router = Router::new()
2831 .route("/", get(list_items))
2832 .route("/{item_id}", get(get_item));
2833
2834 let app = RustApi::new().nest("/api/items", items_router);
2836
2837 let spec = app.openapi_spec();
2839
2840 assert!(
2842 spec.paths.contains_key("/api/items"),
2843 "Should have /api/items in OpenAPI"
2844 );
2845 assert!(
2846 spec.paths.contains_key("/api/items/{item_id}"),
2847 "Should have /api/items/{{item_id}} in OpenAPI"
2848 );
2849
2850 let list_path = spec.paths.get("/api/items").unwrap();
2852 assert!(
2853 list_path.get.is_some(),
2854 "Should have GET operation for /api/items"
2855 );
2856
2857 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
2858 assert!(
2859 get_path.get.is_some(),
2860 "Should have GET operation for /api/items/{{item_id}}"
2861 );
2862
2863 let get_op = get_path.get.as_ref().unwrap();
2865 assert!(!get_op.parameters.is_empty(), "Should have parameters");
2866 let params = &get_op.parameters;
2867 assert!(
2868 params
2869 .iter()
2870 .any(|p| p.name == "item_id" && p.location == "path"),
2871 "Should have 'item_id' path parameter"
2872 );
2873 }
2874}