1use crate::error::Result;
4use crate::events::LifecycleHooks;
5use crate::interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor};
6use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT};
7use crate::response::IntoResponse;
8use crate::router::{MethodRouter, Router};
9use crate::server::Server;
10use std::collections::BTreeMap;
11use std::future::Future;
12use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
13
14pub struct RustApi {
32 router: Router,
33 openapi_spec: rustapi_openapi::OpenApiSpec,
34 layers: LayerStack,
35 body_limit: Option<usize>,
36 interceptors: InterceptorChain,
37 lifecycle_hooks: LifecycleHooks,
38 hot_reload: bool,
39 #[cfg(feature = "http3")]
40 http3_config: Option<crate::http3::Http3Config>,
41 status_config: Option<crate::status::StatusConfig>,
42}
43
44impl RustApi {
45 pub fn new() -> Self {
47 let _ = tracing_subscriber::registry()
49 .with(
50 EnvFilter::try_from_default_env()
51 .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
52 )
53 .with(tracing_subscriber::fmt::layer())
54 .try_init();
55
56 Self {
57 router: Router::new(),
58 openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
59 .register::<rustapi_openapi::ErrorSchema>()
60 .register::<rustapi_openapi::ErrorBodySchema>()
61 .register::<rustapi_openapi::ValidationErrorSchema>()
62 .register::<rustapi_openapi::ValidationErrorBodySchema>()
63 .register::<rustapi_openapi::FieldErrorSchema>(),
64 layers: LayerStack::new(),
65 body_limit: Some(DEFAULT_BODY_LIMIT), interceptors: InterceptorChain::new(),
67 lifecycle_hooks: LifecycleHooks::new(),
68 hot_reload: false,
69 #[cfg(feature = "http3")]
70 http3_config: None,
71 status_config: None,
72 }
73 }
74
75 #[cfg(feature = "swagger-ui")]
99 pub fn auto() -> Self {
100 Self::new().mount_auto_routes_grouped().docs("/docs")
102 }
103
104 #[cfg(not(feature = "swagger-ui"))]
109 pub fn auto() -> Self {
110 Self::new().mount_auto_routes_grouped()
111 }
112
113 pub fn config() -> RustApiConfig {
131 RustApiConfig::new()
132 }
133
134 pub fn body_limit(mut self, limit: usize) -> Self {
155 self.body_limit = Some(limit);
156 self
157 }
158
159 pub fn no_body_limit(mut self) -> Self {
172 self.body_limit = None;
173 self
174 }
175
176 pub fn layer<L>(mut self, layer: L) -> Self
196 where
197 L: MiddlewareLayer,
198 {
199 self.layers.push(Box::new(layer));
200 self
201 }
202
203 pub fn request_interceptor<I>(mut self, interceptor: I) -> Self
235 where
236 I: RequestInterceptor,
237 {
238 self.interceptors.add_request_interceptor(interceptor);
239 self
240 }
241
242 pub fn response_interceptor<I>(mut self, interceptor: I) -> Self
274 where
275 I: ResponseInterceptor,
276 {
277 self.interceptors.add_response_interceptor(interceptor);
278 self
279 }
280
281 pub fn state<S>(self, _state: S) -> Self
297 where
298 S: Clone + Send + Sync + 'static,
299 {
300 let state = _state;
302 let mut app = self;
303 app.router = app.router.state(state);
304 app
305 }
306
307 pub fn on_start<F, Fut>(mut self, hook: F) -> Self
324 where
325 F: FnOnce() -> Fut + Send + 'static,
326 Fut: Future<Output = ()> + Send + 'static,
327 {
328 self.lifecycle_hooks
329 .on_start
330 .push(Box::new(move || Box::pin(hook())));
331 self
332 }
333
334 pub fn on_shutdown<F, Fut>(mut self, hook: F) -> Self
351 where
352 F: FnOnce() -> Fut + Send + 'static,
353 Fut: Future<Output = ()> + Send + 'static,
354 {
355 self.lifecycle_hooks
356 .on_shutdown
357 .push(Box::new(move || Box::pin(hook())));
358 self
359 }
360
361 pub fn hot_reload(mut self, enabled: bool) -> Self {
380 self.hot_reload = enabled;
381 self
382 }
383
384 pub fn register_schema<T: rustapi_openapi::schema::RustApiSchema>(mut self) -> Self {
396 self.openapi_spec = self.openapi_spec.register::<T>();
397 self
398 }
399
400 pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
402 self.openapi_spec.info.title = title.to_string();
405 self.openapi_spec.info.version = version.to_string();
406 self.openapi_spec.info.description = description.map(|d| d.to_string());
407 self
408 }
409
410 pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
412 &self.openapi_spec
413 }
414
415 fn mount_auto_routes_grouped(mut self) -> Self {
416 let routes = crate::auto_route::collect_auto_routes();
417 let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();
419
420 for route in routes {
421 let method_enum = match route.method {
422 "GET" => http::Method::GET,
423 "POST" => http::Method::POST,
424 "PUT" => http::Method::PUT,
425 "DELETE" => http::Method::DELETE,
426 "PATCH" => http::Method::PATCH,
427 _ => http::Method::GET,
428 };
429
430 let path = if route.path.starts_with('/') {
431 route.path.to_string()
432 } else {
433 format!("/{}", route.path)
434 };
435
436 let entry = by_path.entry(path).or_default();
437 entry.insert_boxed_with_operation(method_enum, route.handler, route.operation);
438 }
439
440 #[cfg(feature = "tracing")]
441 let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
442 #[cfg(feature = "tracing")]
443 let path_count = by_path.len();
444
445 for (path, method_router) in by_path {
446 self = self.route(&path, method_router);
447 }
448
449 crate::trace_info!(
450 paths = path_count,
451 routes = route_count,
452 "Auto-registered routes"
453 );
454
455 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
457
458 self
459 }
460
461 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
472 for (method, op) in &method_router.operations {
474 let mut op = op.clone();
475 add_path_params_to_operation(path, &mut op, &BTreeMap::new());
476 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
477 }
478
479 self.router = self.router.route(path, method_router);
480 self
481 }
482
483 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
485 self.route(P::PATH, method_router)
486 }
487
488 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
492 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
493 self.route(path, method_router)
494 }
495
496 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
514 let method_enum = match route.method {
515 "GET" => http::Method::GET,
516 "POST" => http::Method::POST,
517 "PUT" => http::Method::PUT,
518 "DELETE" => http::Method::DELETE,
519 "PATCH" => http::Method::PATCH,
520 _ => http::Method::GET,
521 };
522
523 let mut op = route.operation;
525 add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
526 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
527
528 self.route_with_method(route.path, method_enum, route.handler)
529 }
530
531 fn route_with_method(
533 self,
534 path: &str,
535 method: http::Method,
536 handler: crate::handler::BoxedHandler,
537 ) -> Self {
538 use crate::router::MethodRouter;
539 let path = if !path.starts_with('/') {
548 format!("/{}", path)
549 } else {
550 path.to_string()
551 };
552
553 let mut handlers = std::collections::HashMap::new();
562 handlers.insert(method, handler);
563
564 let method_router = MethodRouter::from_boxed(handlers);
565 self.route(&path, method_router)
566 }
567
568 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
584 let normalized_prefix = normalize_prefix_for_openapi(prefix);
586
587 for (matchit_path, method_router) in router.method_routers() {
590 let display_path = router
592 .registered_routes()
593 .get(matchit_path)
594 .map(|info| info.path.clone())
595 .unwrap_or_else(|| matchit_path.clone());
596
597 let prefixed_path = if display_path == "/" {
599 normalized_prefix.clone()
600 } else {
601 format!("{}{}", normalized_prefix, display_path)
602 };
603
604 for (method, op) in &method_router.operations {
606 let mut op = op.clone();
607 add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
608 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
609 }
610 }
611
612 self.router = self.router.nest(prefix, router);
614 self
615 }
616
617 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
646 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
647 }
648
649 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
666 use crate::router::MethodRouter;
667 use std::collections::HashMap;
668
669 let prefix = config.prefix.clone();
670 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
671
672 let handler: crate::handler::BoxedHandler =
674 std::sync::Arc::new(move |req: crate::Request| {
675 let config = config.clone();
676 let path = req.uri().path().to_string();
677
678 Box::pin(async move {
679 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
680
681 match crate::static_files::StaticFile::serve(relative_path, &config).await {
682 Ok(response) => response,
683 Err(err) => err.into_response(),
684 }
685 })
686 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
687 });
688
689 let mut handlers = HashMap::new();
690 handlers.insert(http::Method::GET, handler);
691 let method_router = MethodRouter::from_boxed(handlers);
692
693 self.route(&catch_all_path, method_router)
694 }
695
696 #[cfg(feature = "compression")]
713 pub fn compression(self) -> Self {
714 self.layer(crate::middleware::CompressionLayer::new())
715 }
716
717 #[cfg(feature = "compression")]
733 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
734 self.layer(crate::middleware::CompressionLayer::with_config(config))
735 }
736
737 #[cfg(feature = "swagger-ui")]
761 pub fn docs(self, path: &str) -> Self {
762 let title = self.openapi_spec.info.title.clone();
763 let version = self.openapi_spec.info.version.clone();
764 let description = self.openapi_spec.info.description.clone();
765
766 self.docs_with_info(path, &title, &version, description.as_deref())
767 }
768
769 #[cfg(feature = "swagger-ui")]
778 pub fn docs_with_info(
779 mut self,
780 path: &str,
781 title: &str,
782 version: &str,
783 description: Option<&str>,
784 ) -> Self {
785 use crate::router::get;
786 self.openapi_spec.info.title = title.to_string();
788 self.openapi_spec.info.version = version.to_string();
789 if let Some(desc) = description {
790 self.openapi_spec.info.description = Some(desc.to_string());
791 }
792
793 let path = path.trim_end_matches('/');
794 let openapi_path = format!("{}/openapi.json", path);
795
796 let spec_value = self.openapi_spec.to_json();
798 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
799 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
801 "{}".to_string()
802 });
803 let openapi_url = openapi_path.clone();
804
805 let spec_handler = move || {
807 let json = spec_json.clone();
808 async move {
809 http::Response::builder()
810 .status(http::StatusCode::OK)
811 .header(http::header::CONTENT_TYPE, "application/json")
812 .body(crate::response::Body::from(json))
813 .unwrap_or_else(|e| {
814 tracing::error!("Failed to build response: {}", e);
815 http::Response::builder()
816 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
817 .body(crate::response::Body::from("Internal Server Error"))
818 .unwrap()
819 })
820 }
821 };
822
823 let docs_handler = move || {
825 let url = openapi_url.clone();
826 async move {
827 let response = rustapi_openapi::swagger_ui_html(&url);
828 response.map(crate::response::Body::Full)
829 }
830 };
831
832 self.route(&openapi_path, get(spec_handler))
833 .route(path, get(docs_handler))
834 }
835
836 #[cfg(feature = "swagger-ui")]
852 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
853 let title = self.openapi_spec.info.title.clone();
854 let version = self.openapi_spec.info.version.clone();
855 let description = self.openapi_spec.info.description.clone();
856
857 self.docs_with_auth_and_info(
858 path,
859 username,
860 password,
861 &title,
862 &version,
863 description.as_deref(),
864 )
865 }
866
867 #[cfg(feature = "swagger-ui")]
883 pub fn docs_with_auth_and_info(
884 mut self,
885 path: &str,
886 username: &str,
887 password: &str,
888 title: &str,
889 version: &str,
890 description: Option<&str>,
891 ) -> Self {
892 use crate::router::MethodRouter;
893 use base64::{engine::general_purpose::STANDARD, Engine};
894 use std::collections::HashMap;
895
896 self.openapi_spec.info.title = title.to_string();
898 self.openapi_spec.info.version = version.to_string();
899 if let Some(desc) = description {
900 self.openapi_spec.info.description = Some(desc.to_string());
901 }
902
903 let path = path.trim_end_matches('/');
904 let openapi_path = format!("{}/openapi.json", path);
905
906 let credentials = format!("{}:{}", username, password);
908 let encoded = STANDARD.encode(credentials.as_bytes());
909 let expected_auth = format!("Basic {}", encoded);
910
911 let spec_value = self.openapi_spec.to_json();
913 let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
914 tracing::error!("Failed to serialize OpenAPI spec: {}", e);
915 "{}".to_string()
916 });
917 let openapi_url = openapi_path.clone();
918 let expected_auth_spec = expected_auth.clone();
919 let expected_auth_docs = expected_auth;
920
921 let spec_handler: crate::handler::BoxedHandler =
923 std::sync::Arc::new(move |req: crate::Request| {
924 let json = spec_json.clone();
925 let expected = expected_auth_spec.clone();
926 Box::pin(async move {
927 if !check_basic_auth(&req, &expected) {
928 return unauthorized_response();
929 }
930 http::Response::builder()
931 .status(http::StatusCode::OK)
932 .header(http::header::CONTENT_TYPE, "application/json")
933 .body(crate::response::Body::from(json))
934 .unwrap_or_else(|e| {
935 tracing::error!("Failed to build response: {}", e);
936 http::Response::builder()
937 .status(http::StatusCode::INTERNAL_SERVER_ERROR)
938 .body(crate::response::Body::from("Internal Server Error"))
939 .unwrap()
940 })
941 })
942 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
943 });
944
945 let docs_handler: crate::handler::BoxedHandler =
947 std::sync::Arc::new(move |req: crate::Request| {
948 let url = openapi_url.clone();
949 let expected = expected_auth_docs.clone();
950 Box::pin(async move {
951 if !check_basic_auth(&req, &expected) {
952 return unauthorized_response();
953 }
954 let response = rustapi_openapi::swagger_ui_html(&url);
955 response.map(crate::response::Body::Full)
956 })
957 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
958 });
959
960 let mut spec_handlers = HashMap::new();
962 spec_handlers.insert(http::Method::GET, spec_handler);
963 let spec_router = MethodRouter::from_boxed(spec_handlers);
964
965 let mut docs_handlers = HashMap::new();
966 docs_handlers.insert(http::Method::GET, docs_handler);
967 let docs_router = MethodRouter::from_boxed(docs_handlers);
968
969 self.route(&openapi_path, spec_router)
970 .route(path, docs_router)
971 }
972
973 pub fn status_page(self) -> Self {
975 self.status_page_with_config(crate::status::StatusConfig::default())
976 }
977
978 pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self {
980 self.status_config = Some(config);
981 self
982 }
983
984 fn print_hot_reload_banner(&self, addr: &str) {
986 if !self.hot_reload {
987 return;
988 }
989
990 std::env::set_var("RUSTAPI_HOT_RELOAD", "1");
992
993 let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD")
994 .map(|v| v == "1")
995 .unwrap_or(false);
996
997 tracing::info!("🔄 Hot-reload mode enabled");
998
999 if is_under_watcher {
1000 tracing::info!(" File watcher active — changes will trigger rebuild + restart");
1001 } else {
1002 tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload");
1003 }
1004
1005 tracing::info!(" Listening on http://{addr}");
1006 }
1007
1008 fn apply_status_page(&mut self) {
1010 if let Some(config) = &self.status_config {
1011 let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new());
1012
1013 self.layers
1015 .push(Box::new(crate::status::StatusLayer::new(monitor.clone())));
1016
1017 use crate::router::MethodRouter;
1019 use std::collections::HashMap;
1020
1021 let monitor = monitor.clone();
1022 let config = config.clone();
1023 let path = config.path.clone(); let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| {
1026 let monitor = monitor.clone();
1027 let config = config.clone();
1028 Box::pin(async move {
1029 crate::status::status_handler(monitor, config)
1030 .await
1031 .into_response()
1032 })
1033 });
1034
1035 let mut handlers = HashMap::new();
1036 handlers.insert(http::Method::GET, handler);
1037 let method_router = MethodRouter::from_boxed(handlers);
1038
1039 let router = std::mem::take(&mut self.router);
1041 self.router = router.route(&path, method_router);
1042 }
1043 }
1044
1045 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1056 self.print_hot_reload_banner(addr);
1058
1059 self.apply_status_page();
1061
1062 if let Some(limit) = self.body_limit {
1064 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1066 }
1067
1068 for hook in self.lifecycle_hooks.on_start {
1070 hook().await;
1071 }
1072
1073 let server = Server::new(self.router, self.layers, self.interceptors);
1074 server.run(addr).await
1075 }
1076
1077 pub async fn run_with_shutdown<F>(
1079 mut self,
1080 addr: impl AsRef<str>,
1081 signal: F,
1082 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1083 where
1084 F: std::future::Future<Output = ()> + Send + 'static,
1085 {
1086 self.print_hot_reload_banner(addr.as_ref());
1088
1089 self.apply_status_page();
1091
1092 if let Some(limit) = self.body_limit {
1093 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1094 }
1095
1096 for hook in self.lifecycle_hooks.on_start {
1098 hook().await;
1099 }
1100
1101 let shutdown_hooks = self.lifecycle_hooks.on_shutdown;
1103 let wrapped_signal = async move {
1104 signal.await;
1105 for hook in shutdown_hooks {
1107 hook().await;
1108 }
1109 };
1110
1111 let server = Server::new(self.router, self.layers, self.interceptors);
1112 server
1113 .run_with_shutdown(addr.as_ref(), wrapped_signal)
1114 .await
1115 }
1116
1117 pub fn into_router(self) -> Router {
1119 self.router
1120 }
1121
1122 pub fn layers(&self) -> &LayerStack {
1124 &self.layers
1125 }
1126
1127 pub fn interceptors(&self) -> &InterceptorChain {
1129 &self.interceptors
1130 }
1131
1132 #[cfg(feature = "http3")]
1146 pub async fn run_http3(
1147 mut self,
1148 config: crate::http3::Http3Config,
1149 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1150 use std::sync::Arc;
1151
1152 self.apply_status_page();
1154
1155 if let Some(limit) = self.body_limit {
1157 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1158 }
1159
1160 let server = crate::http3::Http3Server::new(
1161 &config,
1162 Arc::new(self.router),
1163 Arc::new(self.layers),
1164 Arc::new(self.interceptors),
1165 )
1166 .await?;
1167
1168 server.run().await
1169 }
1170
1171 #[cfg(feature = "http3-dev")]
1185 pub async fn run_http3_dev(
1186 mut self,
1187 addr: &str,
1188 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1189 use std::sync::Arc;
1190
1191 self.apply_status_page();
1193
1194 if let Some(limit) = self.body_limit {
1196 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1197 }
1198
1199 let server = crate::http3::Http3Server::new_with_self_signed(
1200 addr,
1201 Arc::new(self.router),
1202 Arc::new(self.layers),
1203 Arc::new(self.interceptors),
1204 )
1205 .await?;
1206
1207 server.run().await
1208 }
1209
1210 #[cfg(feature = "http3")]
1221 pub fn with_http3(mut self, cert_path: impl Into<String>, key_path: impl Into<String>) -> Self {
1222 self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path));
1223 self
1224 }
1225
1226 #[cfg(feature = "http3")]
1241 pub async fn run_dual_stack(
1242 mut self,
1243 http_addr: &str,
1244 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1245 use std::sync::Arc;
1246
1247 let mut config = self
1248 .http3_config
1249 .take()
1250 .ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
1251
1252 let http_socket: std::net::SocketAddr = http_addr.parse()?;
1253 config.bind_addr = if http_socket.ip().is_ipv6() {
1254 format!("[{}]", http_socket.ip())
1255 } else {
1256 http_socket.ip().to_string()
1257 };
1258 config.port = http_socket.port();
1259 let http_addr = http_socket.to_string();
1260
1261 self.apply_status_page();
1263
1264 if let Some(limit) = self.body_limit {
1266 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1267 }
1268
1269 let router = Arc::new(self.router);
1270 let layers = Arc::new(self.layers);
1271 let interceptors = Arc::new(self.interceptors);
1272
1273 let http1_server =
1274 Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1275 let http3_server =
1276 crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1277
1278 tracing::info!(
1279 http1_addr = %http_addr,
1280 http3_addr = %config.socket_addr(),
1281 "Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1282 );
1283
1284 tokio::try_join!(
1285 http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1286 http3_server.run_with_shutdown(std::future::pending::<()>()),
1287 )?;
1288
1289 Ok(())
1290 }
1291}
1292
1293fn add_path_params_to_operation(
1294 path: &str,
1295 op: &mut rustapi_openapi::Operation,
1296 param_schemas: &BTreeMap<String, String>,
1297) {
1298 let mut params: Vec<String> = Vec::new();
1299 let mut in_brace = false;
1300 let mut current = String::new();
1301
1302 for ch in path.chars() {
1303 match ch {
1304 '{' => {
1305 in_brace = true;
1306 current.clear();
1307 }
1308 '}' => {
1309 if in_brace {
1310 in_brace = false;
1311 if !current.is_empty() {
1312 params.push(current.clone());
1313 }
1314 }
1315 }
1316 _ => {
1317 if in_brace {
1318 current.push(ch);
1319 }
1320 }
1321 }
1322 }
1323
1324 if params.is_empty() {
1325 return;
1326 }
1327
1328 let op_params = &mut op.parameters;
1329
1330 for name in params {
1331 let already = op_params
1332 .iter()
1333 .any(|p| p.location == "path" && p.name == name);
1334 if already {
1335 continue;
1336 }
1337
1338 let schema = if let Some(schema_type) = param_schemas.get(&name) {
1340 schema_type_to_openapi_schema(schema_type)
1341 } else {
1342 infer_path_param_schema(&name)
1343 };
1344
1345 op_params.push(rustapi_openapi::Parameter {
1346 name,
1347 location: "path".to_string(),
1348 required: true,
1349 description: None,
1350 deprecated: None,
1351 schema: Some(schema),
1352 });
1353 }
1354}
1355
1356fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef {
1358 match schema_type.to_lowercase().as_str() {
1359 "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1360 "type": "string",
1361 "format": "uuid"
1362 })),
1363 "integer" | "int" | "int64" | "i64" => {
1364 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1365 "type": "integer",
1366 "format": "int64"
1367 }))
1368 }
1369 "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1370 "type": "integer",
1371 "format": "int32"
1372 })),
1373 "number" | "float" | "f64" | "f32" => {
1374 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1375 "type": "number"
1376 }))
1377 }
1378 "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1379 "type": "boolean"
1380 })),
1381 _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1382 "type": "string"
1383 })),
1384 }
1385}
1386
1387fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
1396 let lower = name.to_lowercase();
1397
1398 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
1400
1401 if is_uuid {
1402 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1403 "type": "string",
1404 "format": "uuid"
1405 }));
1406 }
1407
1408 let is_integer = lower == "page"
1411 || lower == "limit"
1412 || lower == "offset"
1413 || lower == "count"
1414 || lower.ends_with("_count")
1415 || lower.ends_with("_num")
1416 || lower == "year"
1417 || lower == "month"
1418 || lower == "day"
1419 || lower == "index"
1420 || lower == "position";
1421
1422 if is_integer {
1423 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1424 "type": "integer",
1425 "format": "int64"
1426 }))
1427 } else {
1428 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
1429 }
1430}
1431
1432fn normalize_prefix_for_openapi(prefix: &str) -> String {
1439 if prefix.is_empty() {
1441 return "/".to_string();
1442 }
1443
1444 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
1446
1447 if segments.is_empty() {
1449 return "/".to_string();
1450 }
1451
1452 let mut result = String::with_capacity(prefix.len() + 1);
1454 for segment in segments {
1455 result.push('/');
1456 result.push_str(segment);
1457 }
1458
1459 result
1460}
1461
1462impl Default for RustApi {
1463 fn default() -> Self {
1464 Self::new()
1465 }
1466}
1467
1468#[cfg(test)]
1469mod tests {
1470 use super::RustApi;
1471 use crate::extract::{FromRequestParts, State};
1472 use crate::path_params::PathParams;
1473 use crate::request::Request;
1474 use crate::router::{get, post, Router};
1475 use bytes::Bytes;
1476 use http::Method;
1477 use proptest::prelude::*;
1478
1479 #[test]
1480 fn state_is_available_via_extractor() {
1481 let app = RustApi::new().state(123u32);
1482 let router = app.into_router();
1483
1484 let req = http::Request::builder()
1485 .method(Method::GET)
1486 .uri("/test")
1487 .body(())
1488 .unwrap();
1489 let (parts, _) = req.into_parts();
1490
1491 let request = Request::new(
1492 parts,
1493 crate::request::BodyVariant::Buffered(Bytes::new()),
1494 router.state_ref(),
1495 PathParams::new(),
1496 );
1497 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
1498 assert_eq!(value, 123u32);
1499 }
1500
1501 #[test]
1502 fn test_path_param_type_inference_integer() {
1503 use super::infer_path_param_schema;
1504
1505 let int_params = [
1507 "page",
1508 "limit",
1509 "offset",
1510 "count",
1511 "item_count",
1512 "year",
1513 "month",
1514 "day",
1515 "index",
1516 "position",
1517 ];
1518
1519 for name in int_params {
1520 let schema = infer_path_param_schema(name);
1521 match schema {
1522 rustapi_openapi::SchemaRef::Inline(v) => {
1523 assert_eq!(
1524 v.get("type").and_then(|v| v.as_str()),
1525 Some("integer"),
1526 "Expected '{}' to be inferred as integer",
1527 name
1528 );
1529 }
1530 _ => panic!("Expected inline schema for '{}'", name),
1531 }
1532 }
1533 }
1534
1535 #[test]
1536 fn test_path_param_type_inference_uuid() {
1537 use super::infer_path_param_schema;
1538
1539 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
1541
1542 for name in uuid_params {
1543 let schema = infer_path_param_schema(name);
1544 match schema {
1545 rustapi_openapi::SchemaRef::Inline(v) => {
1546 assert_eq!(
1547 v.get("type").and_then(|v| v.as_str()),
1548 Some("string"),
1549 "Expected '{}' to be inferred as string",
1550 name
1551 );
1552 assert_eq!(
1553 v.get("format").and_then(|v| v.as_str()),
1554 Some("uuid"),
1555 "Expected '{}' to have uuid format",
1556 name
1557 );
1558 }
1559 _ => panic!("Expected inline schema for '{}'", name),
1560 }
1561 }
1562 }
1563
1564 #[test]
1565 fn test_path_param_type_inference_string() {
1566 use super::infer_path_param_schema;
1567
1568 let string_params = [
1570 "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId",
1571 ];
1572
1573 for name in string_params {
1574 let schema = infer_path_param_schema(name);
1575 match schema {
1576 rustapi_openapi::SchemaRef::Inline(v) => {
1577 assert_eq!(
1578 v.get("type").and_then(|v| v.as_str()),
1579 Some("string"),
1580 "Expected '{}' to be inferred as string",
1581 name
1582 );
1583 assert!(
1584 v.get("format").is_none()
1585 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
1586 "Expected '{}' to NOT have uuid format",
1587 name
1588 );
1589 }
1590 _ => panic!("Expected inline schema for '{}'", name),
1591 }
1592 }
1593 }
1594
1595 #[test]
1596 fn test_schema_type_to_openapi_schema() {
1597 use super::schema_type_to_openapi_schema;
1598
1599 let uuid_schema = schema_type_to_openapi_schema("uuid");
1601 match uuid_schema {
1602 rustapi_openapi::SchemaRef::Inline(v) => {
1603 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
1604 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid"));
1605 }
1606 _ => panic!("Expected inline schema for uuid"),
1607 }
1608
1609 for schema_type in ["integer", "int", "int64", "i64"] {
1611 let schema = schema_type_to_openapi_schema(schema_type);
1612 match schema {
1613 rustapi_openapi::SchemaRef::Inline(v) => {
1614 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
1615 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64"));
1616 }
1617 _ => panic!("Expected inline schema for {}", schema_type),
1618 }
1619 }
1620
1621 let int32_schema = schema_type_to_openapi_schema("int32");
1623 match int32_schema {
1624 rustapi_openapi::SchemaRef::Inline(v) => {
1625 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
1626 assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32"));
1627 }
1628 _ => panic!("Expected inline schema for int32"),
1629 }
1630
1631 for schema_type in ["number", "float"] {
1633 let schema = schema_type_to_openapi_schema(schema_type);
1634 match schema {
1635 rustapi_openapi::SchemaRef::Inline(v) => {
1636 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number"));
1637 }
1638 _ => panic!("Expected inline schema for {}", schema_type),
1639 }
1640 }
1641
1642 for schema_type in ["boolean", "bool"] {
1644 let schema = schema_type_to_openapi_schema(schema_type);
1645 match schema {
1646 rustapi_openapi::SchemaRef::Inline(v) => {
1647 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean"));
1648 }
1649 _ => panic!("Expected inline schema for {}", schema_type),
1650 }
1651 }
1652
1653 let string_schema = schema_type_to_openapi_schema("string");
1655 match string_schema {
1656 rustapi_openapi::SchemaRef::Inline(v) => {
1657 assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
1658 }
1659 _ => panic!("Expected inline schema for string"),
1660 }
1661 }
1662
1663 proptest! {
1670 #![proptest_config(ProptestConfig::with_cases(100))]
1671
1672 #[test]
1677 fn prop_nested_routes_in_openapi_spec(
1678 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1680 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1682 has_param in any::<bool>(),
1683 ) {
1684 async fn handler() -> &'static str { "handler" }
1685
1686 let prefix = format!("/{}", prefix_segments.join("/"));
1688
1689 let mut route_path = format!("/{}", route_segments.join("/"));
1691 if has_param {
1692 route_path.push_str("/{id}");
1693 }
1694
1695 let nested_router = Router::new().route(&route_path, get(handler));
1697 let app = RustApi::new().nest(&prefix, nested_router);
1698
1699 let expected_openapi_path = format!("{}{}", prefix, route_path);
1701
1702 let spec = app.openapi_spec();
1704
1705 prop_assert!(
1707 spec.paths.contains_key(&expected_openapi_path),
1708 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
1709 expected_openapi_path,
1710 spec.paths.keys().collect::<Vec<_>>()
1711 );
1712
1713 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1715 prop_assert!(
1716 path_item.get.is_some(),
1717 "GET operation should exist for path '{}'",
1718 expected_openapi_path
1719 );
1720 }
1721
1722 #[test]
1727 fn prop_multiple_methods_preserved_in_openapi(
1728 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1729 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1730 ) {
1731 async fn get_handler() -> &'static str { "get" }
1732 async fn post_handler() -> &'static str { "post" }
1733
1734 let prefix = format!("/{}", prefix_segments.join("/"));
1736 let route_path = format!("/{}", route_segments.join("/"));
1737
1738 let get_route_path = format!("{}/get", route_path);
1741 let post_route_path = format!("{}/post", route_path);
1742 let nested_router = Router::new()
1743 .route(&get_route_path, get(get_handler))
1744 .route(&post_route_path, post(post_handler));
1745 let app = RustApi::new().nest(&prefix, nested_router);
1746
1747 let expected_get_path = format!("{}{}", prefix, get_route_path);
1749 let expected_post_path = format!("{}{}", prefix, post_route_path);
1750
1751 let spec = app.openapi_spec();
1753
1754 prop_assert!(
1756 spec.paths.contains_key(&expected_get_path),
1757 "Expected OpenAPI path '{}' not found",
1758 expected_get_path
1759 );
1760 prop_assert!(
1761 spec.paths.contains_key(&expected_post_path),
1762 "Expected OpenAPI path '{}' not found",
1763 expected_post_path
1764 );
1765
1766 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
1768 prop_assert!(
1769 get_path_item.get.is_some(),
1770 "GET operation should exist for path '{}'",
1771 expected_get_path
1772 );
1773
1774 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
1776 prop_assert!(
1777 post_path_item.post.is_some(),
1778 "POST operation should exist for path '{}'",
1779 expected_post_path
1780 );
1781 }
1782
1783 #[test]
1788 fn prop_path_params_in_openapi_after_nesting(
1789 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1790 param_name in "[a-z][a-z0-9]{0,5}",
1791 ) {
1792 async fn handler() -> &'static str { "handler" }
1793
1794 let prefix = format!("/{}", prefix_segments.join("/"));
1796 let route_path = format!("/{{{}}}", param_name);
1797
1798 let nested_router = Router::new().route(&route_path, get(handler));
1800 let app = RustApi::new().nest(&prefix, nested_router);
1801
1802 let expected_openapi_path = format!("{}{}", prefix, route_path);
1804
1805 let spec = app.openapi_spec();
1807
1808 prop_assert!(
1810 spec.paths.contains_key(&expected_openapi_path),
1811 "Expected OpenAPI path '{}' not found",
1812 expected_openapi_path
1813 );
1814
1815 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1817 let get_op = path_item.get.as_ref().unwrap();
1818
1819 prop_assert!(
1820 !get_op.parameters.is_empty(),
1821 "Operation should have parameters for path '{}'",
1822 expected_openapi_path
1823 );
1824
1825 let params = &get_op.parameters;
1826 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
1827 prop_assert!(
1828 has_param,
1829 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
1830 param_name,
1831 params.iter().map(|p| &p.name).collect::<Vec<_>>()
1832 );
1833 }
1834 }
1835
1836 proptest! {
1844 #![proptest_config(ProptestConfig::with_cases(100))]
1845
1846 #[test]
1851 fn prop_rustapi_nest_delegates_to_router_nest(
1852 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1853 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1854 has_param in any::<bool>(),
1855 ) {
1856 async fn handler() -> &'static str { "handler" }
1857
1858 let prefix = format!("/{}", prefix_segments.join("/"));
1860
1861 let mut route_path = format!("/{}", route_segments.join("/"));
1863 if has_param {
1864 route_path.push_str("/{id}");
1865 }
1866
1867 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1869 let nested_router_for_router = Router::new().route(&route_path, get(handler));
1870
1871 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1873 let rustapi_router = rustapi_app.into_router();
1874
1875 let router_app = Router::new().nest(&prefix, nested_router_for_router);
1877
1878 let rustapi_routes = rustapi_router.registered_routes();
1880 let router_routes = router_app.registered_routes();
1881
1882 prop_assert_eq!(
1883 rustapi_routes.len(),
1884 router_routes.len(),
1885 "RustApi and Router should have same number of routes"
1886 );
1887
1888 for (path, info) in router_routes {
1890 prop_assert!(
1891 rustapi_routes.contains_key(path),
1892 "Route '{}' from Router should exist in RustApi routes",
1893 path
1894 );
1895
1896 let rustapi_info = rustapi_routes.get(path).unwrap();
1897 prop_assert_eq!(
1898 &info.path, &rustapi_info.path,
1899 "Display paths should match for route '{}'",
1900 path
1901 );
1902 prop_assert_eq!(
1903 info.methods.len(), rustapi_info.methods.len(),
1904 "Method count should match for route '{}'",
1905 path
1906 );
1907 }
1908 }
1909
1910 #[test]
1915 fn prop_rustapi_nest_includes_routes_in_openapi(
1916 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1917 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1918 has_param in any::<bool>(),
1919 ) {
1920 async fn handler() -> &'static str { "handler" }
1921
1922 let prefix = format!("/{}", prefix_segments.join("/"));
1924
1925 let mut route_path = format!("/{}", route_segments.join("/"));
1927 if has_param {
1928 route_path.push_str("/{id}");
1929 }
1930
1931 let nested_router = Router::new().route(&route_path, get(handler));
1933 let app = RustApi::new().nest(&prefix, nested_router);
1934
1935 let expected_openapi_path = format!("{}{}", prefix, route_path);
1937
1938 let spec = app.openapi_spec();
1940
1941 prop_assert!(
1943 spec.paths.contains_key(&expected_openapi_path),
1944 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
1945 expected_openapi_path,
1946 spec.paths.keys().collect::<Vec<_>>()
1947 );
1948
1949 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1951 prop_assert!(
1952 path_item.get.is_some(),
1953 "GET operation should exist for path '{}'",
1954 expected_openapi_path
1955 );
1956 }
1957
1958 #[test]
1963 fn prop_rustapi_nest_route_matching_identical(
1964 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1965 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1966 param_value in "[a-z0-9]{1,10}",
1967 ) {
1968 use crate::router::RouteMatch;
1969
1970 async fn handler() -> &'static str { "handler" }
1971
1972 let prefix = format!("/{}", prefix_segments.join("/"));
1974 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
1975
1976 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1978 let nested_router_for_router = Router::new().route(&route_path, get(handler));
1979
1980 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1982 let rustapi_router = rustapi_app.into_router();
1983 let router_app = Router::new().nest(&prefix, nested_router_for_router);
1984
1985 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
1987
1988 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
1990 let router_match = router_app.match_route(&full_path, &Method::GET);
1991
1992 match (rustapi_match, router_match) {
1994 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
1995 prop_assert_eq!(
1996 rustapi_params.len(),
1997 router_params.len(),
1998 "Parameter count should match"
1999 );
2000 for (key, value) in &router_params {
2001 prop_assert!(
2002 rustapi_params.contains_key(key),
2003 "RustApi should have parameter '{}'",
2004 key
2005 );
2006 prop_assert_eq!(
2007 rustapi_params.get(key).unwrap(),
2008 value,
2009 "Parameter '{}' value should match",
2010 key
2011 );
2012 }
2013 }
2014 (rustapi_result, router_result) => {
2015 prop_assert!(
2016 false,
2017 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
2018 match rustapi_result {
2019 RouteMatch::Found { .. } => "Found",
2020 RouteMatch::NotFound => "NotFound",
2021 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2022 },
2023 match router_result {
2024 RouteMatch::Found { .. } => "Found",
2025 RouteMatch::NotFound => "NotFound",
2026 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2027 }
2028 );
2029 }
2030 }
2031 }
2032 }
2033
2034 #[test]
2036 fn test_openapi_operations_propagated_during_nesting() {
2037 async fn list_users() -> &'static str {
2038 "list users"
2039 }
2040 async fn get_user() -> &'static str {
2041 "get user"
2042 }
2043 async fn create_user() -> &'static str {
2044 "create user"
2045 }
2046
2047 let users_router = Router::new()
2050 .route("/", get(list_users))
2051 .route("/create", post(create_user))
2052 .route("/{id}", get(get_user));
2053
2054 let app = RustApi::new().nest("/api/v1/users", users_router);
2056
2057 let spec = app.openapi_spec();
2058
2059 assert!(
2061 spec.paths.contains_key("/api/v1/users"),
2062 "Should have /api/v1/users path"
2063 );
2064 let users_path = spec.paths.get("/api/v1/users").unwrap();
2065 assert!(users_path.get.is_some(), "Should have GET operation");
2066
2067 assert!(
2069 spec.paths.contains_key("/api/v1/users/create"),
2070 "Should have /api/v1/users/create path"
2071 );
2072 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
2073 assert!(create_path.post.is_some(), "Should have POST operation");
2074
2075 assert!(
2077 spec.paths.contains_key("/api/v1/users/{id}"),
2078 "Should have /api/v1/users/{{id}} path"
2079 );
2080 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
2081 assert!(
2082 user_path.get.is_some(),
2083 "Should have GET operation for user by id"
2084 );
2085
2086 let get_user_op = user_path.get.as_ref().unwrap();
2088 assert!(!get_user_op.parameters.is_empty(), "Should have parameters");
2089 let params = &get_user_op.parameters;
2090 assert!(
2091 params
2092 .iter()
2093 .any(|p| p.name == "id" && p.location == "path"),
2094 "Should have 'id' path parameter"
2095 );
2096 }
2097
2098 #[test]
2100 fn test_openapi_spec_empty_without_routes() {
2101 let app = RustApi::new();
2102 let spec = app.openapi_spec();
2103
2104 assert!(
2106 spec.paths.is_empty(),
2107 "OpenAPI spec should have no paths without routes"
2108 );
2109 }
2110
2111 #[test]
2116 fn test_rustapi_nest_delegates_to_router_nest() {
2117 use crate::router::RouteMatch;
2118
2119 async fn list_users() -> &'static str {
2120 "list users"
2121 }
2122 async fn get_user() -> &'static str {
2123 "get user"
2124 }
2125 async fn create_user() -> &'static str {
2126 "create user"
2127 }
2128
2129 let users_router = Router::new()
2131 .route("/", get(list_users))
2132 .route("/create", post(create_user))
2133 .route("/{id}", get(get_user));
2134
2135 let app = RustApi::new().nest("/api/v1/users", users_router);
2137 let router = app.into_router();
2138
2139 let routes = router.registered_routes();
2141 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
2142
2143 assert!(
2145 routes.contains_key("/api/v1/users"),
2146 "Should have /api/v1/users route"
2147 );
2148 assert!(
2149 routes.contains_key("/api/v1/users/create"),
2150 "Should have /api/v1/users/create route"
2151 );
2152 assert!(
2153 routes.contains_key("/api/v1/users/:id"),
2154 "Should have /api/v1/users/:id route"
2155 );
2156
2157 match router.match_route("/api/v1/users", &Method::GET) {
2159 RouteMatch::Found { params, .. } => {
2160 assert!(params.is_empty(), "Root route should have no params");
2161 }
2162 _ => panic!("GET /api/v1/users should be found"),
2163 }
2164
2165 match router.match_route("/api/v1/users/create", &Method::POST) {
2166 RouteMatch::Found { params, .. } => {
2167 assert!(params.is_empty(), "Create route should have no params");
2168 }
2169 _ => panic!("POST /api/v1/users/create should be found"),
2170 }
2171
2172 match router.match_route("/api/v1/users/123", &Method::GET) {
2173 RouteMatch::Found { params, .. } => {
2174 assert_eq!(
2175 params.get("id"),
2176 Some(&"123".to_string()),
2177 "Should extract id param"
2178 );
2179 }
2180 _ => panic!("GET /api/v1/users/123 should be found"),
2181 }
2182
2183 match router.match_route("/api/v1/users", &Method::DELETE) {
2185 RouteMatch::MethodNotAllowed { allowed } => {
2186 assert!(allowed.contains(&Method::GET), "Should allow GET");
2187 }
2188 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
2189 }
2190 }
2191
2192 #[test]
2197 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
2198 async fn list_items() -> &'static str {
2199 "list items"
2200 }
2201 async fn get_item() -> &'static str {
2202 "get item"
2203 }
2204
2205 let items_router = Router::new()
2207 .route("/", get(list_items))
2208 .route("/{item_id}", get(get_item));
2209
2210 let app = RustApi::new().nest("/api/items", items_router);
2212
2213 let spec = app.openapi_spec();
2215
2216 assert!(
2218 spec.paths.contains_key("/api/items"),
2219 "Should have /api/items in OpenAPI"
2220 );
2221 assert!(
2222 spec.paths.contains_key("/api/items/{item_id}"),
2223 "Should have /api/items/{{item_id}} in OpenAPI"
2224 );
2225
2226 let list_path = spec.paths.get("/api/items").unwrap();
2228 assert!(
2229 list_path.get.is_some(),
2230 "Should have GET operation for /api/items"
2231 );
2232
2233 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
2234 assert!(
2235 get_path.get.is_some(),
2236 "Should have GET operation for /api/items/{{item_id}}"
2237 );
2238
2239 let get_op = get_path.get.as_ref().unwrap();
2241 assert!(!get_op.parameters.is_empty(), "Should have parameters");
2242 let params = &get_op.parameters;
2243 assert!(
2244 params
2245 .iter()
2246 .any(|p| p.name == "item_id" && p.location == "path"),
2247 "Should have 'item_id' path parameter"
2248 );
2249 }
2250}
2251
2252#[cfg(feature = "swagger-ui")]
2254fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
2255 req.headers()
2256 .get(http::header::AUTHORIZATION)
2257 .and_then(|v| v.to_str().ok())
2258 .map(|auth| auth == expected)
2259 .unwrap_or(false)
2260}
2261
2262#[cfg(feature = "swagger-ui")]
2264fn unauthorized_response() -> crate::Response {
2265 http::Response::builder()
2266 .status(http::StatusCode::UNAUTHORIZED)
2267 .header(
2268 http::header::WWW_AUTHENTICATE,
2269 "Basic realm=\"API Documentation\"",
2270 )
2271 .header(http::header::CONTENT_TYPE, "text/plain")
2272 .body(crate::response::Body::from("Unauthorized"))
2273 .unwrap()
2274}
2275
2276pub struct RustApiConfig {
2278 docs_path: Option<String>,
2279 docs_enabled: bool,
2280 api_title: String,
2281 api_version: String,
2282 api_description: Option<String>,
2283 body_limit: Option<usize>,
2284 layers: LayerStack,
2285}
2286
2287impl Default for RustApiConfig {
2288 fn default() -> Self {
2289 Self::new()
2290 }
2291}
2292
2293impl RustApiConfig {
2294 pub fn new() -> Self {
2295 Self {
2296 docs_path: Some("/docs".to_string()),
2297 docs_enabled: true,
2298 api_title: "RustAPI".to_string(),
2299 api_version: "1.0.0".to_string(),
2300 api_description: None,
2301 body_limit: None,
2302 layers: LayerStack::new(),
2303 }
2304 }
2305
2306 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
2308 self.docs_path = Some(path.into());
2309 self
2310 }
2311
2312 pub fn docs_enabled(mut self, enabled: bool) -> Self {
2314 self.docs_enabled = enabled;
2315 self
2316 }
2317
2318 pub fn openapi_info(
2320 mut self,
2321 title: impl Into<String>,
2322 version: impl Into<String>,
2323 description: Option<impl Into<String>>,
2324 ) -> Self {
2325 self.api_title = title.into();
2326 self.api_version = version.into();
2327 self.api_description = description.map(|d| d.into());
2328 self
2329 }
2330
2331 pub fn body_limit(mut self, limit: usize) -> Self {
2333 self.body_limit = Some(limit);
2334 self
2335 }
2336
2337 pub fn layer<L>(mut self, layer: L) -> Self
2339 where
2340 L: MiddlewareLayer,
2341 {
2342 self.layers.push(Box::new(layer));
2343 self
2344 }
2345
2346 pub fn build(self) -> RustApi {
2348 let mut app = RustApi::new().mount_auto_routes_grouped();
2349
2350 if let Some(limit) = self.body_limit {
2352 app = app.body_limit(limit);
2353 }
2354
2355 app = app.openapi_info(
2356 &self.api_title,
2357 &self.api_version,
2358 self.api_description.as_deref(),
2359 );
2360
2361 #[cfg(feature = "swagger-ui")]
2362 if self.docs_enabled {
2363 if let Some(path) = self.docs_path {
2364 app = app.docs(&path);
2365 }
2366 }
2367
2368 app.layers.extend(self.layers);
2371
2372 app
2373 }
2374
2375 pub async fn run(
2377 self,
2378 addr: impl AsRef<str>,
2379 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2380 self.build().run(addr.as_ref()).await
2381 }
2382}