1use crate::error::Result;
4use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT};
5use crate::response::IntoResponse;
6use crate::router::{MethodRouter, Router};
7use crate::server::Server;
8use std::collections::HashMap;
9use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
10
11pub struct RustApi {
29 router: Router,
30 openapi_spec: rustapi_openapi::OpenApiSpec,
31 layers: LayerStack,
32 body_limit: Option<usize>,
33}
34
35impl RustApi {
36 pub fn new() -> Self {
38 let _ = tracing_subscriber::registry()
40 .with(
41 EnvFilter::try_from_default_env()
42 .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
43 )
44 .with(tracing_subscriber::fmt::layer())
45 .try_init();
46
47 Self {
48 router: Router::new(),
49 openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
50 .register::<rustapi_openapi::ErrorSchema>()
51 .register::<rustapi_openapi::ErrorBodySchema>()
52 .register::<rustapi_openapi::ValidationErrorSchema>()
53 .register::<rustapi_openapi::ValidationErrorBodySchema>()
54 .register::<rustapi_openapi::FieldErrorSchema>(),
55 layers: LayerStack::new(),
56 body_limit: Some(DEFAULT_BODY_LIMIT), }
58 }
59
60 #[cfg(feature = "swagger-ui")]
84 pub fn auto() -> Self {
85 Self::new().mount_auto_routes_grouped().docs("/docs")
87 }
88
89 #[cfg(not(feature = "swagger-ui"))]
94 pub fn auto() -> Self {
95 Self::new().mount_auto_routes_grouped()
96 }
97
98 pub fn config() -> RustApiConfig {
116 RustApiConfig::new()
117 }
118
119 pub fn body_limit(mut self, limit: usize) -> Self {
140 self.body_limit = Some(limit);
141 self
142 }
143
144 pub fn no_body_limit(mut self) -> Self {
157 self.body_limit = None;
158 self
159 }
160
161 pub fn layer<L>(mut self, layer: L) -> Self
181 where
182 L: MiddlewareLayer,
183 {
184 self.layers.push(Box::new(layer));
185 self
186 }
187
188 pub fn state<S>(self, _state: S) -> Self
204 where
205 S: Clone + Send + Sync + 'static,
206 {
207 let state = _state;
209 let mut app = self;
210 app.router = app.router.state(state);
211 app
212 }
213
214 pub fn register_schema<T: for<'a> rustapi_openapi::Schema<'a>>(mut self) -> Self {
226 self.openapi_spec = self.openapi_spec.register::<T>();
227 self
228 }
229
230 pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
232 self.openapi_spec.info.title = title.to_string();
235 self.openapi_spec.info.version = version.to_string();
236 self.openapi_spec.info.description = description.map(|d| d.to_string());
237 self
238 }
239
240 pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
242 &self.openapi_spec
243 }
244
245 fn mount_auto_routes_grouped(mut self) -> Self {
246 let routes = crate::auto_route::collect_auto_routes();
247 let mut by_path: HashMap<String, MethodRouter> = HashMap::new();
248
249 for route in routes {
250 let method_enum = match route.method {
251 "GET" => http::Method::GET,
252 "POST" => http::Method::POST,
253 "PUT" => http::Method::PUT,
254 "DELETE" => http::Method::DELETE,
255 "PATCH" => http::Method::PATCH,
256 _ => http::Method::GET,
257 };
258
259 let path = if route.path.starts_with('/') {
260 route.path.to_string()
261 } else {
262 format!("/{}", route.path)
263 };
264
265 let entry = by_path.entry(path).or_default();
266 entry.insert_boxed_with_operation(method_enum, route.handler, route.operation);
267 }
268
269 let route_count = by_path
270 .values()
271 .map(|mr| mr.allowed_methods().len())
272 .sum::<usize>();
273 let path_count = by_path.len();
274
275 for (path, method_router) in by_path {
276 self = self.route(&path, method_router);
277 }
278
279 tracing::info!(
280 paths = path_count,
281 routes = route_count,
282 "Auto-registered routes"
283 );
284
285 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
287
288 self
289 }
290
291 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
302 for (method, op) in &method_router.operations {
304 let mut op = op.clone();
305 add_path_params_to_operation(path, &mut op);
306 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
307 }
308
309 self.router = self.router.route(path, method_router);
310 self
311 }
312
313 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
317 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
318 self.route(path, method_router)
319 }
320
321 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
339 let method_enum = match route.method {
340 "GET" => http::Method::GET,
341 "POST" => http::Method::POST,
342 "PUT" => http::Method::PUT,
343 "DELETE" => http::Method::DELETE,
344 "PATCH" => http::Method::PATCH,
345 _ => http::Method::GET,
346 };
347
348 let mut op = route.operation;
350 add_path_params_to_operation(route.path, &mut op);
351 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
352
353 self.route_with_method(route.path, method_enum, route.handler)
354 }
355
356 fn route_with_method(
358 self,
359 path: &str,
360 method: http::Method,
361 handler: crate::handler::BoxedHandler,
362 ) -> Self {
363 use crate::router::MethodRouter;
364 let path = if !path.starts_with('/') {
373 format!("/{}", path)
374 } else {
375 path.to_string()
376 };
377
378 let mut handlers = std::collections::HashMap::new();
387 handlers.insert(method, handler);
388
389 let method_router = MethodRouter::from_boxed(handlers);
390 self.route(&path, method_router)
391 }
392
393 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
409 let normalized_prefix = normalize_prefix_for_openapi(prefix);
411
412 for (matchit_path, method_router) in router.method_routers() {
415 let display_path = router
417 .registered_routes()
418 .get(matchit_path)
419 .map(|info| info.path.clone())
420 .unwrap_or_else(|| matchit_path.clone());
421
422 let prefixed_path = if display_path == "/" {
424 normalized_prefix.clone()
425 } else {
426 format!("{}{}", normalized_prefix, display_path)
427 };
428
429 for (method, op) in &method_router.operations {
431 let mut op = op.clone();
432 add_path_params_to_operation(&prefixed_path, &mut op);
433 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
434 }
435 }
436
437 self.router = self.router.nest(prefix, router);
439 self
440 }
441
442 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
471 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
472 }
473
474 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
491 use crate::router::MethodRouter;
492 use std::collections::HashMap;
493
494 let prefix = config.prefix.clone();
495 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
496
497 let handler: crate::handler::BoxedHandler =
499 std::sync::Arc::new(move |req: crate::Request| {
500 let config = config.clone();
501 let path = req.uri().path().to_string();
502
503 Box::pin(async move {
504 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
505
506 match crate::static_files::StaticFile::serve(relative_path, &config).await {
507 Ok(response) => response,
508 Err(err) => err.into_response(),
509 }
510 })
511 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
512 });
513
514 let mut handlers = HashMap::new();
515 handlers.insert(http::Method::GET, handler);
516 let method_router = MethodRouter::from_boxed(handlers);
517
518 self.route(&catch_all_path, method_router)
519 }
520
521 #[cfg(feature = "compression")]
538 pub fn compression(self) -> Self {
539 self.layer(crate::middleware::CompressionLayer::new())
540 }
541
542 #[cfg(feature = "compression")]
558 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
559 self.layer(crate::middleware::CompressionLayer::with_config(config))
560 }
561
562 #[cfg(feature = "swagger-ui")]
578 pub fn docs(self, path: &str) -> Self {
579 let title = self.openapi_spec.info.title.clone();
580 let version = self.openapi_spec.info.version.clone();
581 let description = self.openapi_spec.info.description.clone();
582
583 self.docs_with_info(path, &title, &version, description.as_deref())
584 }
585
586 #[cfg(feature = "swagger-ui")]
595 pub fn docs_with_info(
596 mut self,
597 path: &str,
598 title: &str,
599 version: &str,
600 description: Option<&str>,
601 ) -> Self {
602 use crate::router::get;
603 self.openapi_spec.info.title = title.to_string();
605 self.openapi_spec.info.version = version.to_string();
606 if let Some(desc) = description {
607 self.openapi_spec.info.description = Some(desc.to_string());
608 }
609
610 let path = path.trim_end_matches('/');
611 let openapi_path = format!("{}/openapi.json", path);
612
613 let spec_json =
615 serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
616 let openapi_url = openapi_path.clone();
617
618 let spec_handler = move || {
620 let json = spec_json.clone();
621 async move {
622 http::Response::builder()
623 .status(http::StatusCode::OK)
624 .header(http::header::CONTENT_TYPE, "application/json")
625 .body(http_body_util::Full::new(bytes::Bytes::from(json)))
626 .unwrap()
627 }
628 };
629
630 let docs_handler = move || {
632 let url = openapi_url.clone();
633 async move { rustapi_openapi::swagger_ui_html(&url) }
634 };
635
636 self.route(&openapi_path, get(spec_handler))
637 .route(path, get(docs_handler))
638 }
639
640 #[cfg(feature = "swagger-ui")]
656 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
657 let title = self.openapi_spec.info.title.clone();
658 let version = self.openapi_spec.info.version.clone();
659 let description = self.openapi_spec.info.description.clone();
660
661 self.docs_with_auth_and_info(
662 path,
663 username,
664 password,
665 &title,
666 &version,
667 description.as_deref(),
668 )
669 }
670
671 #[cfg(feature = "swagger-ui")]
687 pub fn docs_with_auth_and_info(
688 mut self,
689 path: &str,
690 username: &str,
691 password: &str,
692 title: &str,
693 version: &str,
694 description: Option<&str>,
695 ) -> Self {
696 use crate::router::MethodRouter;
697 use base64::{engine::general_purpose::STANDARD, Engine};
698 use std::collections::HashMap;
699
700 self.openapi_spec.info.title = title.to_string();
702 self.openapi_spec.info.version = version.to_string();
703 if let Some(desc) = description {
704 self.openapi_spec.info.description = Some(desc.to_string());
705 }
706
707 let path = path.trim_end_matches('/');
708 let openapi_path = format!("{}/openapi.json", path);
709
710 let credentials = format!("{}:{}", username, password);
712 let encoded = STANDARD.encode(credentials.as_bytes());
713 let expected_auth = format!("Basic {}", encoded);
714
715 let spec_json =
717 serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
718 let openapi_url = openapi_path.clone();
719 let expected_auth_spec = expected_auth.clone();
720 let expected_auth_docs = expected_auth;
721
722 let spec_handler: crate::handler::BoxedHandler =
724 std::sync::Arc::new(move |req: crate::Request| {
725 let json = spec_json.clone();
726 let expected = expected_auth_spec.clone();
727 Box::pin(async move {
728 if !check_basic_auth(&req, &expected) {
729 return unauthorized_response();
730 }
731 http::Response::builder()
732 .status(http::StatusCode::OK)
733 .header(http::header::CONTENT_TYPE, "application/json")
734 .body(http_body_util::Full::new(bytes::Bytes::from(json)))
735 .unwrap()
736 })
737 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
738 });
739
740 let docs_handler: crate::handler::BoxedHandler =
742 std::sync::Arc::new(move |req: crate::Request| {
743 let url = openapi_url.clone();
744 let expected = expected_auth_docs.clone();
745 Box::pin(async move {
746 if !check_basic_auth(&req, &expected) {
747 return unauthorized_response();
748 }
749 rustapi_openapi::swagger_ui_html(&url)
750 })
751 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
752 });
753
754 let mut spec_handlers = HashMap::new();
756 spec_handlers.insert(http::Method::GET, spec_handler);
757 let spec_router = MethodRouter::from_boxed(spec_handlers);
758
759 let mut docs_handlers = HashMap::new();
760 docs_handlers.insert(http::Method::GET, docs_handler);
761 let docs_router = MethodRouter::from_boxed(docs_handlers);
762
763 self.route(&openapi_path, spec_router)
764 .route(path, docs_router)
765 }
766
767 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
778 if let Some(limit) = self.body_limit {
780 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
782 }
783
784 let server = Server::new(self.router, self.layers);
785 server.run(addr).await
786 }
787
788 pub fn into_router(self) -> Router {
790 self.router
791 }
792
793 pub fn layers(&self) -> &LayerStack {
795 &self.layers
796 }
797}
798
799fn add_path_params_to_operation(path: &str, op: &mut rustapi_openapi::Operation) {
800 let mut params: Vec<String> = Vec::new();
801 let mut in_brace = false;
802 let mut current = String::new();
803
804 for ch in path.chars() {
805 match ch {
806 '{' => {
807 in_brace = true;
808 current.clear();
809 }
810 '}' => {
811 if in_brace {
812 in_brace = false;
813 if !current.is_empty() {
814 params.push(current.clone());
815 }
816 }
817 }
818 _ => {
819 if in_brace {
820 current.push(ch);
821 }
822 }
823 }
824 }
825
826 if params.is_empty() {
827 return;
828 }
829
830 let op_params = op.parameters.get_or_insert_with(Vec::new);
831
832 for name in params {
833 let already = op_params
834 .iter()
835 .any(|p| p.location == "path" && p.name == name);
836 if already {
837 continue;
838 }
839
840 op_params.push(rustapi_openapi::Parameter {
841 name,
842 location: "path".to_string(),
843 required: true,
844 description: None,
845 schema: rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" })),
846 });
847 }
848}
849
850fn normalize_prefix_for_openapi(prefix: &str) -> String {
857 if prefix.is_empty() {
859 return "/".to_string();
860 }
861
862 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
864
865 if segments.is_empty() {
867 return "/".to_string();
868 }
869
870 let mut result = String::with_capacity(prefix.len() + 1);
872 for segment in segments {
873 result.push('/');
874 result.push_str(segment);
875 }
876
877 result
878}
879
880impl Default for RustApi {
881 fn default() -> Self {
882 Self::new()
883 }
884}
885
886#[cfg(test)]
887mod tests {
888 use super::RustApi;
889 use crate::extract::{FromRequestParts, State};
890 use crate::request::Request;
891 use crate::router::{get, post, Router};
892 use bytes::Bytes;
893 use http::Method;
894 use proptest::prelude::*;
895 use std::collections::HashMap;
896
897 #[test]
898 fn state_is_available_via_extractor() {
899 let app = RustApi::new().state(123u32);
900 let router = app.into_router();
901
902 let req = http::Request::builder()
903 .method(Method::GET)
904 .uri("/test")
905 .body(())
906 .unwrap();
907 let (parts, _) = req.into_parts();
908
909 let request = Request::new(parts, Bytes::new(), router.state_ref(), HashMap::new());
910 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
911 assert_eq!(value, 123u32);
912 }
913
914 proptest! {
921 #![proptest_config(ProptestConfig::with_cases(100))]
922
923 #[test]
928 fn prop_nested_routes_in_openapi_spec(
929 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
931 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
933 has_param in any::<bool>(),
934 ) {
935 async fn handler() -> &'static str { "handler" }
936
937 let prefix = format!("/{}", prefix_segments.join("/"));
939
940 let mut route_path = format!("/{}", route_segments.join("/"));
942 if has_param {
943 route_path.push_str("/{id}");
944 }
945
946 let nested_router = Router::new().route(&route_path, get(handler));
948 let app = RustApi::new().nest(&prefix, nested_router);
949
950 let expected_openapi_path = format!("{}{}", prefix, route_path);
952
953 let spec = app.openapi_spec();
955
956 prop_assert!(
958 spec.paths.contains_key(&expected_openapi_path),
959 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
960 expected_openapi_path,
961 spec.paths.keys().collect::<Vec<_>>()
962 );
963
964 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
966 prop_assert!(
967 path_item.get.is_some(),
968 "GET operation should exist for path '{}'",
969 expected_openapi_path
970 );
971 }
972
973 #[test]
978 fn prop_multiple_methods_preserved_in_openapi(
979 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
980 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
981 ) {
982 async fn get_handler() -> &'static str { "get" }
983 async fn post_handler() -> &'static str { "post" }
984
985 let prefix = format!("/{}", prefix_segments.join("/"));
987 let route_path = format!("/{}", route_segments.join("/"));
988
989 let get_route_path = format!("{}/get", route_path);
992 let post_route_path = format!("{}/post", route_path);
993 let nested_router = Router::new()
994 .route(&get_route_path, get(get_handler))
995 .route(&post_route_path, post(post_handler));
996 let app = RustApi::new().nest(&prefix, nested_router);
997
998 let expected_get_path = format!("{}{}", prefix, get_route_path);
1000 let expected_post_path = format!("{}{}", prefix, post_route_path);
1001
1002 let spec = app.openapi_spec();
1004
1005 prop_assert!(
1007 spec.paths.contains_key(&expected_get_path),
1008 "Expected OpenAPI path '{}' not found",
1009 expected_get_path
1010 );
1011 prop_assert!(
1012 spec.paths.contains_key(&expected_post_path),
1013 "Expected OpenAPI path '{}' not found",
1014 expected_post_path
1015 );
1016
1017 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
1019 prop_assert!(
1020 get_path_item.get.is_some(),
1021 "GET operation should exist for path '{}'",
1022 expected_get_path
1023 );
1024
1025 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
1027 prop_assert!(
1028 post_path_item.post.is_some(),
1029 "POST operation should exist for path '{}'",
1030 expected_post_path
1031 );
1032 }
1033
1034 #[test]
1039 fn prop_path_params_in_openapi_after_nesting(
1040 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1041 param_name in "[a-z][a-z0-9]{0,5}",
1042 ) {
1043 async fn handler() -> &'static str { "handler" }
1044
1045 let prefix = format!("/{}", prefix_segments.join("/"));
1047 let route_path = format!("/{{{}}}", param_name);
1048
1049 let nested_router = Router::new().route(&route_path, get(handler));
1051 let app = RustApi::new().nest(&prefix, nested_router);
1052
1053 let expected_openapi_path = format!("{}{}", prefix, route_path);
1055
1056 let spec = app.openapi_spec();
1058
1059 prop_assert!(
1061 spec.paths.contains_key(&expected_openapi_path),
1062 "Expected OpenAPI path '{}' not found",
1063 expected_openapi_path
1064 );
1065
1066 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1068 let get_op = path_item.get.as_ref().unwrap();
1069
1070 prop_assert!(
1071 get_op.parameters.is_some(),
1072 "Operation should have parameters for path '{}'",
1073 expected_openapi_path
1074 );
1075
1076 let params = get_op.parameters.as_ref().unwrap();
1077 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
1078 prop_assert!(
1079 has_param,
1080 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
1081 param_name,
1082 params.iter().map(|p| &p.name).collect::<Vec<_>>()
1083 );
1084 }
1085 }
1086
1087 proptest! {
1095 #![proptest_config(ProptestConfig::with_cases(100))]
1096
1097 #[test]
1102 fn prop_rustapi_nest_delegates_to_router_nest(
1103 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1104 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1105 has_param in any::<bool>(),
1106 ) {
1107 async fn handler() -> &'static str { "handler" }
1108
1109 let prefix = format!("/{}", prefix_segments.join("/"));
1111
1112 let mut route_path = format!("/{}", route_segments.join("/"));
1114 if has_param {
1115 route_path.push_str("/{id}");
1116 }
1117
1118 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1120 let nested_router_for_router = Router::new().route(&route_path, get(handler));
1121
1122 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1124 let rustapi_router = rustapi_app.into_router();
1125
1126 let router_app = Router::new().nest(&prefix, nested_router_for_router);
1128
1129 let rustapi_routes = rustapi_router.registered_routes();
1131 let router_routes = router_app.registered_routes();
1132
1133 prop_assert_eq!(
1134 rustapi_routes.len(),
1135 router_routes.len(),
1136 "RustApi and Router should have same number of routes"
1137 );
1138
1139 for (path, info) in router_routes {
1141 prop_assert!(
1142 rustapi_routes.contains_key(path),
1143 "Route '{}' from Router should exist in RustApi routes",
1144 path
1145 );
1146
1147 let rustapi_info = rustapi_routes.get(path).unwrap();
1148 prop_assert_eq!(
1149 &info.path, &rustapi_info.path,
1150 "Display paths should match for route '{}'",
1151 path
1152 );
1153 prop_assert_eq!(
1154 info.methods.len(), rustapi_info.methods.len(),
1155 "Method count should match for route '{}'",
1156 path
1157 );
1158 }
1159 }
1160
1161 #[test]
1166 fn prop_rustapi_nest_includes_routes_in_openapi(
1167 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1168 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1169 has_param in any::<bool>(),
1170 ) {
1171 async fn handler() -> &'static str { "handler" }
1172
1173 let prefix = format!("/{}", prefix_segments.join("/"));
1175
1176 let mut route_path = format!("/{}", route_segments.join("/"));
1178 if has_param {
1179 route_path.push_str("/{id}");
1180 }
1181
1182 let nested_router = Router::new().route(&route_path, get(handler));
1184 let app = RustApi::new().nest(&prefix, nested_router);
1185
1186 let expected_openapi_path = format!("{}{}", prefix, route_path);
1188
1189 let spec = app.openapi_spec();
1191
1192 prop_assert!(
1194 spec.paths.contains_key(&expected_openapi_path),
1195 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
1196 expected_openapi_path,
1197 spec.paths.keys().collect::<Vec<_>>()
1198 );
1199
1200 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1202 prop_assert!(
1203 path_item.get.is_some(),
1204 "GET operation should exist for path '{}'",
1205 expected_openapi_path
1206 );
1207 }
1208
1209 #[test]
1214 fn prop_rustapi_nest_route_matching_identical(
1215 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1216 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1217 param_value in "[a-z0-9]{1,10}",
1218 ) {
1219 use crate::router::RouteMatch;
1220
1221 async fn handler() -> &'static str { "handler" }
1222
1223 let prefix = format!("/{}", prefix_segments.join("/"));
1225 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
1226
1227 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1229 let nested_router_for_router = Router::new().route(&route_path, get(handler));
1230
1231 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1233 let rustapi_router = rustapi_app.into_router();
1234 let router_app = Router::new().nest(&prefix, nested_router_for_router);
1235
1236 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
1238
1239 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
1241 let router_match = router_app.match_route(&full_path, &Method::GET);
1242
1243 match (rustapi_match, router_match) {
1245 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
1246 prop_assert_eq!(
1247 rustapi_params.len(),
1248 router_params.len(),
1249 "Parameter count should match"
1250 );
1251 for (key, value) in &router_params {
1252 prop_assert!(
1253 rustapi_params.contains_key(key),
1254 "RustApi should have parameter '{}'",
1255 key
1256 );
1257 prop_assert_eq!(
1258 rustapi_params.get(key).unwrap(),
1259 value,
1260 "Parameter '{}' value should match",
1261 key
1262 );
1263 }
1264 }
1265 (rustapi_result, router_result) => {
1266 prop_assert!(
1267 false,
1268 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
1269 match rustapi_result {
1270 RouteMatch::Found { .. } => "Found",
1271 RouteMatch::NotFound => "NotFound",
1272 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
1273 },
1274 match router_result {
1275 RouteMatch::Found { .. } => "Found",
1276 RouteMatch::NotFound => "NotFound",
1277 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
1278 }
1279 );
1280 }
1281 }
1282 }
1283 }
1284
1285 #[test]
1287 fn test_openapi_operations_propagated_during_nesting() {
1288 async fn list_users() -> &'static str { "list users" }
1289 async fn get_user() -> &'static str { "get user" }
1290 async fn create_user() -> &'static str { "create user" }
1291
1292 let users_router = Router::new()
1295 .route("/", get(list_users))
1296 .route("/create", post(create_user))
1297 .route("/{id}", get(get_user));
1298
1299 let app = RustApi::new().nest("/api/v1/users", users_router);
1301
1302 let spec = app.openapi_spec();
1303
1304 assert!(spec.paths.contains_key("/api/v1/users"), "Should have /api/v1/users path");
1306 let users_path = spec.paths.get("/api/v1/users").unwrap();
1307 assert!(users_path.get.is_some(), "Should have GET operation");
1308
1309 assert!(spec.paths.contains_key("/api/v1/users/create"), "Should have /api/v1/users/create path");
1311 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
1312 assert!(create_path.post.is_some(), "Should have POST operation");
1313
1314 assert!(spec.paths.contains_key("/api/v1/users/{id}"), "Should have /api/v1/users/{{id}} path");
1316 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
1317 assert!(user_path.get.is_some(), "Should have GET operation for user by id");
1318
1319 let get_user_op = user_path.get.as_ref().unwrap();
1321 assert!(get_user_op.parameters.is_some(), "Should have parameters");
1322 let params = get_user_op.parameters.as_ref().unwrap();
1323 assert!(params.iter().any(|p| p.name == "id" && p.location == "path"),
1324 "Should have 'id' path parameter");
1325 }
1326
1327 #[test]
1329 fn test_openapi_spec_empty_without_routes() {
1330 let app = RustApi::new();
1331 let spec = app.openapi_spec();
1332
1333 assert!(spec.paths.is_empty(), "OpenAPI spec should have no paths without routes");
1335 }
1336
1337 #[test]
1342 fn test_rustapi_nest_delegates_to_router_nest() {
1343 use crate::router::RouteMatch;
1344
1345 async fn list_users() -> &'static str { "list users" }
1346 async fn get_user() -> &'static str { "get user" }
1347 async fn create_user() -> &'static str { "create user" }
1348
1349 let users_router = Router::new()
1351 .route("/", get(list_users))
1352 .route("/create", post(create_user))
1353 .route("/{id}", get(get_user));
1354
1355 let app = RustApi::new().nest("/api/v1/users", users_router);
1357 let router = app.into_router();
1358
1359 let routes = router.registered_routes();
1361 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
1362
1363 assert!(routes.contains_key("/api/v1/users"), "Should have /api/v1/users route");
1365 assert!(routes.contains_key("/api/v1/users/create"), "Should have /api/v1/users/create route");
1366 assert!(routes.contains_key("/api/v1/users/:id"), "Should have /api/v1/users/:id route");
1367
1368 match router.match_route("/api/v1/users", &Method::GET) {
1370 RouteMatch::Found { params, .. } => {
1371 assert!(params.is_empty(), "Root route should have no params");
1372 }
1373 _ => panic!("GET /api/v1/users should be found"),
1374 }
1375
1376 match router.match_route("/api/v1/users/create", &Method::POST) {
1377 RouteMatch::Found { params, .. } => {
1378 assert!(params.is_empty(), "Create route should have no params");
1379 }
1380 _ => panic!("POST /api/v1/users/create should be found"),
1381 }
1382
1383 match router.match_route("/api/v1/users/123", &Method::GET) {
1384 RouteMatch::Found { params, .. } => {
1385 assert_eq!(params.get("id"), Some(&"123".to_string()), "Should extract id param");
1386 }
1387 _ => panic!("GET /api/v1/users/123 should be found"),
1388 }
1389
1390 match router.match_route("/api/v1/users", &Method::DELETE) {
1392 RouteMatch::MethodNotAllowed { allowed } => {
1393 assert!(allowed.contains(&Method::GET), "Should allow GET");
1394 }
1395 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
1396 }
1397 }
1398
1399 #[test]
1404 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
1405 async fn list_items() -> &'static str { "list items" }
1406 async fn get_item() -> &'static str { "get item" }
1407
1408 let items_router = Router::new()
1410 .route("/", get(list_items))
1411 .route("/{item_id}", get(get_item));
1412
1413 let app = RustApi::new().nest("/api/items", items_router);
1415
1416 let spec = app.openapi_spec();
1418
1419 assert!(spec.paths.contains_key("/api/items"), "Should have /api/items in OpenAPI");
1421 assert!(spec.paths.contains_key("/api/items/{item_id}"), "Should have /api/items/{{item_id}} in OpenAPI");
1422
1423 let list_path = spec.paths.get("/api/items").unwrap();
1425 assert!(list_path.get.is_some(), "Should have GET operation for /api/items");
1426
1427 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
1428 assert!(get_path.get.is_some(), "Should have GET operation for /api/items/{{item_id}}");
1429
1430 let get_op = get_path.get.as_ref().unwrap();
1432 assert!(get_op.parameters.is_some(), "Should have parameters");
1433 let params = get_op.parameters.as_ref().unwrap();
1434 assert!(params.iter().any(|p| p.name == "item_id" && p.location == "path"),
1435 "Should have 'item_id' path parameter");
1436 }
1437}
1438
1439#[cfg(feature = "swagger-ui")]
1441fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1442 req.headers()
1443 .get(http::header::AUTHORIZATION)
1444 .and_then(|v| v.to_str().ok())
1445 .map(|auth| auth == expected)
1446 .unwrap_or(false)
1447}
1448
1449#[cfg(feature = "swagger-ui")]
1451fn unauthorized_response() -> crate::Response {
1452 http::Response::builder()
1453 .status(http::StatusCode::UNAUTHORIZED)
1454 .header(
1455 http::header::WWW_AUTHENTICATE,
1456 "Basic realm=\"API Documentation\"",
1457 )
1458 .header(http::header::CONTENT_TYPE, "text/plain")
1459 .body(http_body_util::Full::new(bytes::Bytes::from(
1460 "Unauthorized",
1461 )))
1462 .unwrap()
1463}
1464
1465pub struct RustApiConfig {
1467 docs_path: Option<String>,
1468 docs_enabled: bool,
1469 api_title: String,
1470 api_version: String,
1471 api_description: Option<String>,
1472 body_limit: Option<usize>,
1473 layers: LayerStack,
1474}
1475
1476impl Default for RustApiConfig {
1477 fn default() -> Self {
1478 Self::new()
1479 }
1480}
1481
1482impl RustApiConfig {
1483 pub fn new() -> Self {
1484 Self {
1485 docs_path: Some("/docs".to_string()),
1486 docs_enabled: true,
1487 api_title: "RustAPI".to_string(),
1488 api_version: "1.0.0".to_string(),
1489 api_description: None,
1490 body_limit: None,
1491 layers: LayerStack::new(),
1492 }
1493 }
1494
1495 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
1497 self.docs_path = Some(path.into());
1498 self
1499 }
1500
1501 pub fn docs_enabled(mut self, enabled: bool) -> Self {
1503 self.docs_enabled = enabled;
1504 self
1505 }
1506
1507 pub fn openapi_info(
1509 mut self,
1510 title: impl Into<String>,
1511 version: impl Into<String>,
1512 description: Option<impl Into<String>>,
1513 ) -> Self {
1514 self.api_title = title.into();
1515 self.api_version = version.into();
1516 self.api_description = description.map(|d| d.into());
1517 self
1518 }
1519
1520 pub fn body_limit(mut self, limit: usize) -> Self {
1522 self.body_limit = Some(limit);
1523 self
1524 }
1525
1526 pub fn layer<L>(mut self, layer: L) -> Self
1528 where
1529 L: MiddlewareLayer,
1530 {
1531 self.layers.push(Box::new(layer));
1532 self
1533 }
1534
1535 pub fn build(self) -> RustApi {
1537 let mut app = RustApi::new().mount_auto_routes_grouped();
1538
1539 if let Some(limit) = self.body_limit {
1541 app = app.body_limit(limit);
1542 }
1543
1544 app = app.openapi_info(
1545 &self.api_title,
1546 &self.api_version,
1547 self.api_description.as_deref(),
1548 );
1549
1550 #[cfg(feature = "swagger-ui")]
1551 if self.docs_enabled {
1552 if let Some(path) = self.docs_path {
1553 app = app.docs(&path);
1554 }
1555 }
1556
1557 app.layers.extend(self.layers);
1560
1561 app
1562 }
1563
1564 pub async fn run(
1566 self,
1567 addr: impl AsRef<str>,
1568 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1569 self.build().run(addr.as_ref()).await
1570 }
1571}