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: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
270 let path_count = by_path.len();
271
272 for (path, method_router) in by_path {
273 self = self.route(&path, method_router);
274 }
275
276 tracing::info!(
277 paths = path_count,
278 routes = route_count,
279 "Auto-registered routes"
280 );
281
282 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
284
285 self
286 }
287
288 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
299 for (method, op) in &method_router.operations {
301 let mut op = op.clone();
302 add_path_params_to_operation(path, &mut op);
303 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
304 }
305
306 self.router = self.router.route(path, method_router);
307 self
308 }
309
310 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
314 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
315 self.route(path, method_router)
316 }
317
318 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
336 let method_enum = match route.method {
337 "GET" => http::Method::GET,
338 "POST" => http::Method::POST,
339 "PUT" => http::Method::PUT,
340 "DELETE" => http::Method::DELETE,
341 "PATCH" => http::Method::PATCH,
342 _ => http::Method::GET,
343 };
344
345 let mut op = route.operation;
347 add_path_params_to_operation(route.path, &mut op);
348 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
349
350 self.route_with_method(route.path, method_enum, route.handler)
351 }
352
353 fn route_with_method(
355 self,
356 path: &str,
357 method: http::Method,
358 handler: crate::handler::BoxedHandler,
359 ) -> Self {
360 use crate::router::MethodRouter;
361 let path = if !path.starts_with('/') {
370 format!("/{}", path)
371 } else {
372 path.to_string()
373 };
374
375 let mut handlers = std::collections::HashMap::new();
384 handlers.insert(method, handler);
385
386 let method_router = MethodRouter::from_boxed(handlers);
387 self.route(&path, method_router)
388 }
389
390 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
406 let normalized_prefix = normalize_prefix_for_openapi(prefix);
408
409 for (matchit_path, method_router) in router.method_routers() {
412 let display_path = router
414 .registered_routes()
415 .get(matchit_path)
416 .map(|info| info.path.clone())
417 .unwrap_or_else(|| matchit_path.clone());
418
419 let prefixed_path = if display_path == "/" {
421 normalized_prefix.clone()
422 } else {
423 format!("{}{}", normalized_prefix, display_path)
424 };
425
426 for (method, op) in &method_router.operations {
428 let mut op = op.clone();
429 add_path_params_to_operation(&prefixed_path, &mut op);
430 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
431 }
432 }
433
434 self.router = self.router.nest(prefix, router);
436 self
437 }
438
439 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
468 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
469 }
470
471 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
488 use crate::router::MethodRouter;
489 use std::collections::HashMap;
490
491 let prefix = config.prefix.clone();
492 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
493
494 let handler: crate::handler::BoxedHandler =
496 std::sync::Arc::new(move |req: crate::Request| {
497 let config = config.clone();
498 let path = req.uri().path().to_string();
499
500 Box::pin(async move {
501 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
502
503 match crate::static_files::StaticFile::serve(relative_path, &config).await {
504 Ok(response) => response,
505 Err(err) => err.into_response(),
506 }
507 })
508 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
509 });
510
511 let mut handlers = HashMap::new();
512 handlers.insert(http::Method::GET, handler);
513 let method_router = MethodRouter::from_boxed(handlers);
514
515 self.route(&catch_all_path, method_router)
516 }
517
518 #[cfg(feature = "compression")]
535 pub fn compression(self) -> Self {
536 self.layer(crate::middleware::CompressionLayer::new())
537 }
538
539 #[cfg(feature = "compression")]
555 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
556 self.layer(crate::middleware::CompressionLayer::with_config(config))
557 }
558
559 #[cfg(feature = "swagger-ui")]
575 pub fn docs(self, path: &str) -> Self {
576 let title = self.openapi_spec.info.title.clone();
577 let version = self.openapi_spec.info.version.clone();
578 let description = self.openapi_spec.info.description.clone();
579
580 self.docs_with_info(path, &title, &version, description.as_deref())
581 }
582
583 #[cfg(feature = "swagger-ui")]
592 pub fn docs_with_info(
593 mut self,
594 path: &str,
595 title: &str,
596 version: &str,
597 description: Option<&str>,
598 ) -> Self {
599 use crate::router::get;
600 self.openapi_spec.info.title = title.to_string();
602 self.openapi_spec.info.version = version.to_string();
603 if let Some(desc) = description {
604 self.openapi_spec.info.description = Some(desc.to_string());
605 }
606
607 let path = path.trim_end_matches('/');
608 let openapi_path = format!("{}/openapi.json", path);
609
610 let spec_json =
612 serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
613 let openapi_url = openapi_path.clone();
614
615 let spec_handler = move || {
617 let json = spec_json.clone();
618 async move {
619 http::Response::builder()
620 .status(http::StatusCode::OK)
621 .header(http::header::CONTENT_TYPE, "application/json")
622 .body(http_body_util::Full::new(bytes::Bytes::from(json)))
623 .unwrap()
624 }
625 };
626
627 let docs_handler = move || {
629 let url = openapi_url.clone();
630 async move { rustapi_openapi::swagger_ui_html(&url) }
631 };
632
633 self.route(&openapi_path, get(spec_handler))
634 .route(path, get(docs_handler))
635 }
636
637 #[cfg(feature = "swagger-ui")]
653 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
654 let title = self.openapi_spec.info.title.clone();
655 let version = self.openapi_spec.info.version.clone();
656 let description = self.openapi_spec.info.description.clone();
657
658 self.docs_with_auth_and_info(
659 path,
660 username,
661 password,
662 &title,
663 &version,
664 description.as_deref(),
665 )
666 }
667
668 #[cfg(feature = "swagger-ui")]
684 pub fn docs_with_auth_and_info(
685 mut self,
686 path: &str,
687 username: &str,
688 password: &str,
689 title: &str,
690 version: &str,
691 description: Option<&str>,
692 ) -> Self {
693 use crate::router::MethodRouter;
694 use base64::{engine::general_purpose::STANDARD, Engine};
695 use std::collections::HashMap;
696
697 self.openapi_spec.info.title = title.to_string();
699 self.openapi_spec.info.version = version.to_string();
700 if let Some(desc) = description {
701 self.openapi_spec.info.description = Some(desc.to_string());
702 }
703
704 let path = path.trim_end_matches('/');
705 let openapi_path = format!("{}/openapi.json", path);
706
707 let credentials = format!("{}:{}", username, password);
709 let encoded = STANDARD.encode(credentials.as_bytes());
710 let expected_auth = format!("Basic {}", encoded);
711
712 let spec_json =
714 serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
715 let openapi_url = openapi_path.clone();
716 let expected_auth_spec = expected_auth.clone();
717 let expected_auth_docs = expected_auth;
718
719 let spec_handler: crate::handler::BoxedHandler =
721 std::sync::Arc::new(move |req: crate::Request| {
722 let json = spec_json.clone();
723 let expected = expected_auth_spec.clone();
724 Box::pin(async move {
725 if !check_basic_auth(&req, &expected) {
726 return unauthorized_response();
727 }
728 http::Response::builder()
729 .status(http::StatusCode::OK)
730 .header(http::header::CONTENT_TYPE, "application/json")
731 .body(http_body_util::Full::new(bytes::Bytes::from(json)))
732 .unwrap()
733 })
734 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
735 });
736
737 let docs_handler: crate::handler::BoxedHandler =
739 std::sync::Arc::new(move |req: crate::Request| {
740 let url = openapi_url.clone();
741 let expected = expected_auth_docs.clone();
742 Box::pin(async move {
743 if !check_basic_auth(&req, &expected) {
744 return unauthorized_response();
745 }
746 rustapi_openapi::swagger_ui_html(&url)
747 })
748 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
749 });
750
751 let mut spec_handlers = HashMap::new();
753 spec_handlers.insert(http::Method::GET, spec_handler);
754 let spec_router = MethodRouter::from_boxed(spec_handlers);
755
756 let mut docs_handlers = HashMap::new();
757 docs_handlers.insert(http::Method::GET, docs_handler);
758 let docs_router = MethodRouter::from_boxed(docs_handlers);
759
760 self.route(&openapi_path, spec_router)
761 .route(path, docs_router)
762 }
763
764 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
775 if let Some(limit) = self.body_limit {
777 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
779 }
780
781 let server = Server::new(self.router, self.layers);
782 server.run(addr).await
783 }
784
785 pub fn into_router(self) -> Router {
787 self.router
788 }
789
790 pub fn layers(&self) -> &LayerStack {
792 &self.layers
793 }
794}
795
796fn add_path_params_to_operation(path: &str, op: &mut rustapi_openapi::Operation) {
797 let mut params: Vec<String> = Vec::new();
798 let mut in_brace = false;
799 let mut current = String::new();
800
801 for ch in path.chars() {
802 match ch {
803 '{' => {
804 in_brace = true;
805 current.clear();
806 }
807 '}' => {
808 if in_brace {
809 in_brace = false;
810 if !current.is_empty() {
811 params.push(current.clone());
812 }
813 }
814 }
815 _ => {
816 if in_brace {
817 current.push(ch);
818 }
819 }
820 }
821 }
822
823 if params.is_empty() {
824 return;
825 }
826
827 let op_params = op.parameters.get_or_insert_with(Vec::new);
828
829 for name in params {
830 let already = op_params
831 .iter()
832 .any(|p| p.location == "path" && p.name == name);
833 if already {
834 continue;
835 }
836
837 op_params.push(rustapi_openapi::Parameter {
838 name,
839 location: "path".to_string(),
840 required: true,
841 description: None,
842 schema: rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" })),
843 });
844 }
845}
846
847fn normalize_prefix_for_openapi(prefix: &str) -> String {
854 if prefix.is_empty() {
856 return "/".to_string();
857 }
858
859 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
861
862 if segments.is_empty() {
864 return "/".to_string();
865 }
866
867 let mut result = String::with_capacity(prefix.len() + 1);
869 for segment in segments {
870 result.push('/');
871 result.push_str(segment);
872 }
873
874 result
875}
876
877impl Default for RustApi {
878 fn default() -> Self {
879 Self::new()
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::RustApi;
886 use crate::extract::{FromRequestParts, State};
887 use crate::request::Request;
888 use crate::router::{get, post, Router};
889 use bytes::Bytes;
890 use http::Method;
891 use proptest::prelude::*;
892 use std::collections::HashMap;
893
894 #[test]
895 fn state_is_available_via_extractor() {
896 let app = RustApi::new().state(123u32);
897 let router = app.into_router();
898
899 let req = http::Request::builder()
900 .method(Method::GET)
901 .uri("/test")
902 .body(())
903 .unwrap();
904 let (parts, _) = req.into_parts();
905
906 let request = Request::new(parts, Bytes::new(), router.state_ref(), HashMap::new());
907 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
908 assert_eq!(value, 123u32);
909 }
910
911 proptest! {
918 #![proptest_config(ProptestConfig::with_cases(100))]
919
920 #[test]
925 fn prop_nested_routes_in_openapi_spec(
926 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
928 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
930 has_param in any::<bool>(),
931 ) {
932 async fn handler() -> &'static str { "handler" }
933
934 let prefix = format!("/{}", prefix_segments.join("/"));
936
937 let mut route_path = format!("/{}", route_segments.join("/"));
939 if has_param {
940 route_path.push_str("/{id}");
941 }
942
943 let nested_router = Router::new().route(&route_path, get(handler));
945 let app = RustApi::new().nest(&prefix, nested_router);
946
947 let expected_openapi_path = format!("{}{}", prefix, route_path);
949
950 let spec = app.openapi_spec();
952
953 prop_assert!(
955 spec.paths.contains_key(&expected_openapi_path),
956 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
957 expected_openapi_path,
958 spec.paths.keys().collect::<Vec<_>>()
959 );
960
961 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
963 prop_assert!(
964 path_item.get.is_some(),
965 "GET operation should exist for path '{}'",
966 expected_openapi_path
967 );
968 }
969
970 #[test]
975 fn prop_multiple_methods_preserved_in_openapi(
976 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
977 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
978 ) {
979 async fn get_handler() -> &'static str { "get" }
980 async fn post_handler() -> &'static str { "post" }
981
982 let prefix = format!("/{}", prefix_segments.join("/"));
984 let route_path = format!("/{}", route_segments.join("/"));
985
986 let get_route_path = format!("{}/get", route_path);
989 let post_route_path = format!("{}/post", route_path);
990 let nested_router = Router::new()
991 .route(&get_route_path, get(get_handler))
992 .route(&post_route_path, post(post_handler));
993 let app = RustApi::new().nest(&prefix, nested_router);
994
995 let expected_get_path = format!("{}{}", prefix, get_route_path);
997 let expected_post_path = format!("{}{}", prefix, post_route_path);
998
999 let spec = app.openapi_spec();
1001
1002 prop_assert!(
1004 spec.paths.contains_key(&expected_get_path),
1005 "Expected OpenAPI path '{}' not found",
1006 expected_get_path
1007 );
1008 prop_assert!(
1009 spec.paths.contains_key(&expected_post_path),
1010 "Expected OpenAPI path '{}' not found",
1011 expected_post_path
1012 );
1013
1014 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
1016 prop_assert!(
1017 get_path_item.get.is_some(),
1018 "GET operation should exist for path '{}'",
1019 expected_get_path
1020 );
1021
1022 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
1024 prop_assert!(
1025 post_path_item.post.is_some(),
1026 "POST operation should exist for path '{}'",
1027 expected_post_path
1028 );
1029 }
1030
1031 #[test]
1036 fn prop_path_params_in_openapi_after_nesting(
1037 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1038 param_name in "[a-z][a-z0-9]{0,5}",
1039 ) {
1040 async fn handler() -> &'static str { "handler" }
1041
1042 let prefix = format!("/{}", prefix_segments.join("/"));
1044 let route_path = format!("/{{{}}}", param_name);
1045
1046 let nested_router = Router::new().route(&route_path, get(handler));
1048 let app = RustApi::new().nest(&prefix, nested_router);
1049
1050 let expected_openapi_path = format!("{}{}", prefix, route_path);
1052
1053 let spec = app.openapi_spec();
1055
1056 prop_assert!(
1058 spec.paths.contains_key(&expected_openapi_path),
1059 "Expected OpenAPI path '{}' not found",
1060 expected_openapi_path
1061 );
1062
1063 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1065 let get_op = path_item.get.as_ref().unwrap();
1066
1067 prop_assert!(
1068 get_op.parameters.is_some(),
1069 "Operation should have parameters for path '{}'",
1070 expected_openapi_path
1071 );
1072
1073 let params = get_op.parameters.as_ref().unwrap();
1074 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
1075 prop_assert!(
1076 has_param,
1077 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
1078 param_name,
1079 params.iter().map(|p| &p.name).collect::<Vec<_>>()
1080 );
1081 }
1082 }
1083
1084 proptest! {
1092 #![proptest_config(ProptestConfig::with_cases(100))]
1093
1094 #[test]
1099 fn prop_rustapi_nest_delegates_to_router_nest(
1100 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1101 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1102 has_param in any::<bool>(),
1103 ) {
1104 async fn handler() -> &'static str { "handler" }
1105
1106 let prefix = format!("/{}", prefix_segments.join("/"));
1108
1109 let mut route_path = format!("/{}", route_segments.join("/"));
1111 if has_param {
1112 route_path.push_str("/{id}");
1113 }
1114
1115 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1117 let nested_router_for_router = Router::new().route(&route_path, get(handler));
1118
1119 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1121 let rustapi_router = rustapi_app.into_router();
1122
1123 let router_app = Router::new().nest(&prefix, nested_router_for_router);
1125
1126 let rustapi_routes = rustapi_router.registered_routes();
1128 let router_routes = router_app.registered_routes();
1129
1130 prop_assert_eq!(
1131 rustapi_routes.len(),
1132 router_routes.len(),
1133 "RustApi and Router should have same number of routes"
1134 );
1135
1136 for (path, info) in router_routes {
1138 prop_assert!(
1139 rustapi_routes.contains_key(path),
1140 "Route '{}' from Router should exist in RustApi routes",
1141 path
1142 );
1143
1144 let rustapi_info = rustapi_routes.get(path).unwrap();
1145 prop_assert_eq!(
1146 &info.path, &rustapi_info.path,
1147 "Display paths should match for route '{}'",
1148 path
1149 );
1150 prop_assert_eq!(
1151 info.methods.len(), rustapi_info.methods.len(),
1152 "Method count should match for route '{}'",
1153 path
1154 );
1155 }
1156 }
1157
1158 #[test]
1163 fn prop_rustapi_nest_includes_routes_in_openapi(
1164 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1165 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1166 has_param in any::<bool>(),
1167 ) {
1168 async fn handler() -> &'static str { "handler" }
1169
1170 let prefix = format!("/{}", prefix_segments.join("/"));
1172
1173 let mut route_path = format!("/{}", route_segments.join("/"));
1175 if has_param {
1176 route_path.push_str("/{id}");
1177 }
1178
1179 let nested_router = Router::new().route(&route_path, get(handler));
1181 let app = RustApi::new().nest(&prefix, nested_router);
1182
1183 let expected_openapi_path = format!("{}{}", prefix, route_path);
1185
1186 let spec = app.openapi_spec();
1188
1189 prop_assert!(
1191 spec.paths.contains_key(&expected_openapi_path),
1192 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
1193 expected_openapi_path,
1194 spec.paths.keys().collect::<Vec<_>>()
1195 );
1196
1197 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1199 prop_assert!(
1200 path_item.get.is_some(),
1201 "GET operation should exist for path '{}'",
1202 expected_openapi_path
1203 );
1204 }
1205
1206 #[test]
1211 fn prop_rustapi_nest_route_matching_identical(
1212 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1213 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1214 param_value in "[a-z0-9]{1,10}",
1215 ) {
1216 use crate::router::RouteMatch;
1217
1218 async fn handler() -> &'static str { "handler" }
1219
1220 let prefix = format!("/{}", prefix_segments.join("/"));
1222 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
1223
1224 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1226 let nested_router_for_router = Router::new().route(&route_path, get(handler));
1227
1228 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1230 let rustapi_router = rustapi_app.into_router();
1231 let router_app = Router::new().nest(&prefix, nested_router_for_router);
1232
1233 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
1235
1236 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
1238 let router_match = router_app.match_route(&full_path, &Method::GET);
1239
1240 match (rustapi_match, router_match) {
1242 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
1243 prop_assert_eq!(
1244 rustapi_params.len(),
1245 router_params.len(),
1246 "Parameter count should match"
1247 );
1248 for (key, value) in &router_params {
1249 prop_assert!(
1250 rustapi_params.contains_key(key),
1251 "RustApi should have parameter '{}'",
1252 key
1253 );
1254 prop_assert_eq!(
1255 rustapi_params.get(key).unwrap(),
1256 value,
1257 "Parameter '{}' value should match",
1258 key
1259 );
1260 }
1261 }
1262 (rustapi_result, router_result) => {
1263 prop_assert!(
1264 false,
1265 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
1266 match rustapi_result {
1267 RouteMatch::Found { .. } => "Found",
1268 RouteMatch::NotFound => "NotFound",
1269 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
1270 },
1271 match router_result {
1272 RouteMatch::Found { .. } => "Found",
1273 RouteMatch::NotFound => "NotFound",
1274 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
1275 }
1276 );
1277 }
1278 }
1279 }
1280 }
1281
1282 #[test]
1284 fn test_openapi_operations_propagated_during_nesting() {
1285 async fn list_users() -> &'static str {
1286 "list users"
1287 }
1288 async fn get_user() -> &'static str {
1289 "get user"
1290 }
1291 async fn create_user() -> &'static str {
1292 "create user"
1293 }
1294
1295 let users_router = Router::new()
1298 .route("/", get(list_users))
1299 .route("/create", post(create_user))
1300 .route("/{id}", get(get_user));
1301
1302 let app = RustApi::new().nest("/api/v1/users", users_router);
1304
1305 let spec = app.openapi_spec();
1306
1307 assert!(
1309 spec.paths.contains_key("/api/v1/users"),
1310 "Should have /api/v1/users path"
1311 );
1312 let users_path = spec.paths.get("/api/v1/users").unwrap();
1313 assert!(users_path.get.is_some(), "Should have GET operation");
1314
1315 assert!(
1317 spec.paths.contains_key("/api/v1/users/create"),
1318 "Should have /api/v1/users/create path"
1319 );
1320 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
1321 assert!(create_path.post.is_some(), "Should have POST operation");
1322
1323 assert!(
1325 spec.paths.contains_key("/api/v1/users/{id}"),
1326 "Should have /api/v1/users/{{id}} path"
1327 );
1328 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
1329 assert!(
1330 user_path.get.is_some(),
1331 "Should have GET operation for user by id"
1332 );
1333
1334 let get_user_op = user_path.get.as_ref().unwrap();
1336 assert!(get_user_op.parameters.is_some(), "Should have parameters");
1337 let params = get_user_op.parameters.as_ref().unwrap();
1338 assert!(
1339 params
1340 .iter()
1341 .any(|p| p.name == "id" && p.location == "path"),
1342 "Should have 'id' path parameter"
1343 );
1344 }
1345
1346 #[test]
1348 fn test_openapi_spec_empty_without_routes() {
1349 let app = RustApi::new();
1350 let spec = app.openapi_spec();
1351
1352 assert!(
1354 spec.paths.is_empty(),
1355 "OpenAPI spec should have no paths without routes"
1356 );
1357 }
1358
1359 #[test]
1364 fn test_rustapi_nest_delegates_to_router_nest() {
1365 use crate::router::RouteMatch;
1366
1367 async fn list_users() -> &'static str {
1368 "list users"
1369 }
1370 async fn get_user() -> &'static str {
1371 "get user"
1372 }
1373 async fn create_user() -> &'static str {
1374 "create user"
1375 }
1376
1377 let users_router = Router::new()
1379 .route("/", get(list_users))
1380 .route("/create", post(create_user))
1381 .route("/{id}", get(get_user));
1382
1383 let app = RustApi::new().nest("/api/v1/users", users_router);
1385 let router = app.into_router();
1386
1387 let routes = router.registered_routes();
1389 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
1390
1391 assert!(
1393 routes.contains_key("/api/v1/users"),
1394 "Should have /api/v1/users route"
1395 );
1396 assert!(
1397 routes.contains_key("/api/v1/users/create"),
1398 "Should have /api/v1/users/create route"
1399 );
1400 assert!(
1401 routes.contains_key("/api/v1/users/:id"),
1402 "Should have /api/v1/users/:id route"
1403 );
1404
1405 match router.match_route("/api/v1/users", &Method::GET) {
1407 RouteMatch::Found { params, .. } => {
1408 assert!(params.is_empty(), "Root route should have no params");
1409 }
1410 _ => panic!("GET /api/v1/users should be found"),
1411 }
1412
1413 match router.match_route("/api/v1/users/create", &Method::POST) {
1414 RouteMatch::Found { params, .. } => {
1415 assert!(params.is_empty(), "Create route should have no params");
1416 }
1417 _ => panic!("POST /api/v1/users/create should be found"),
1418 }
1419
1420 match router.match_route("/api/v1/users/123", &Method::GET) {
1421 RouteMatch::Found { params, .. } => {
1422 assert_eq!(
1423 params.get("id"),
1424 Some(&"123".to_string()),
1425 "Should extract id param"
1426 );
1427 }
1428 _ => panic!("GET /api/v1/users/123 should be found"),
1429 }
1430
1431 match router.match_route("/api/v1/users", &Method::DELETE) {
1433 RouteMatch::MethodNotAllowed { allowed } => {
1434 assert!(allowed.contains(&Method::GET), "Should allow GET");
1435 }
1436 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
1437 }
1438 }
1439
1440 #[test]
1445 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
1446 async fn list_items() -> &'static str {
1447 "list items"
1448 }
1449 async fn get_item() -> &'static str {
1450 "get item"
1451 }
1452
1453 let items_router = Router::new()
1455 .route("/", get(list_items))
1456 .route("/{item_id}", get(get_item));
1457
1458 let app = RustApi::new().nest("/api/items", items_router);
1460
1461 let spec = app.openapi_spec();
1463
1464 assert!(
1466 spec.paths.contains_key("/api/items"),
1467 "Should have /api/items in OpenAPI"
1468 );
1469 assert!(
1470 spec.paths.contains_key("/api/items/{item_id}"),
1471 "Should have /api/items/{{item_id}} in OpenAPI"
1472 );
1473
1474 let list_path = spec.paths.get("/api/items").unwrap();
1476 assert!(
1477 list_path.get.is_some(),
1478 "Should have GET operation for /api/items"
1479 );
1480
1481 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
1482 assert!(
1483 get_path.get.is_some(),
1484 "Should have GET operation for /api/items/{{item_id}}"
1485 );
1486
1487 let get_op = get_path.get.as_ref().unwrap();
1489 assert!(get_op.parameters.is_some(), "Should have parameters");
1490 let params = get_op.parameters.as_ref().unwrap();
1491 assert!(
1492 params
1493 .iter()
1494 .any(|p| p.name == "item_id" && p.location == "path"),
1495 "Should have 'item_id' path parameter"
1496 );
1497 }
1498}
1499
1500#[cfg(feature = "swagger-ui")]
1502fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1503 req.headers()
1504 .get(http::header::AUTHORIZATION)
1505 .and_then(|v| v.to_str().ok())
1506 .map(|auth| auth == expected)
1507 .unwrap_or(false)
1508}
1509
1510#[cfg(feature = "swagger-ui")]
1512fn unauthorized_response() -> crate::Response {
1513 http::Response::builder()
1514 .status(http::StatusCode::UNAUTHORIZED)
1515 .header(
1516 http::header::WWW_AUTHENTICATE,
1517 "Basic realm=\"API Documentation\"",
1518 )
1519 .header(http::header::CONTENT_TYPE, "text/plain")
1520 .body(http_body_util::Full::new(bytes::Bytes::from(
1521 "Unauthorized",
1522 )))
1523 .unwrap()
1524}
1525
1526pub struct RustApiConfig {
1528 docs_path: Option<String>,
1529 docs_enabled: bool,
1530 api_title: String,
1531 api_version: String,
1532 api_description: Option<String>,
1533 body_limit: Option<usize>,
1534 layers: LayerStack,
1535}
1536
1537impl Default for RustApiConfig {
1538 fn default() -> Self {
1539 Self::new()
1540 }
1541}
1542
1543impl RustApiConfig {
1544 pub fn new() -> Self {
1545 Self {
1546 docs_path: Some("/docs".to_string()),
1547 docs_enabled: true,
1548 api_title: "RustAPI".to_string(),
1549 api_version: "1.0.0".to_string(),
1550 api_description: None,
1551 body_limit: None,
1552 layers: LayerStack::new(),
1553 }
1554 }
1555
1556 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
1558 self.docs_path = Some(path.into());
1559 self
1560 }
1561
1562 pub fn docs_enabled(mut self, enabled: bool) -> Self {
1564 self.docs_enabled = enabled;
1565 self
1566 }
1567
1568 pub fn openapi_info(
1570 mut self,
1571 title: impl Into<String>,
1572 version: impl Into<String>,
1573 description: Option<impl Into<String>>,
1574 ) -> Self {
1575 self.api_title = title.into();
1576 self.api_version = version.into();
1577 self.api_description = description.map(|d| d.into());
1578 self
1579 }
1580
1581 pub fn body_limit(mut self, limit: usize) -> Self {
1583 self.body_limit = Some(limit);
1584 self
1585 }
1586
1587 pub fn layer<L>(mut self, layer: L) -> Self
1589 where
1590 L: MiddlewareLayer,
1591 {
1592 self.layers.push(Box::new(layer));
1593 self
1594 }
1595
1596 pub fn build(self) -> RustApi {
1598 let mut app = RustApi::new().mount_auto_routes_grouped();
1599
1600 if let Some(limit) = self.body_limit {
1602 app = app.body_limit(limit);
1603 }
1604
1605 app = app.openapi_info(
1606 &self.api_title,
1607 &self.api_version,
1608 self.api_description.as_deref(),
1609 );
1610
1611 #[cfg(feature = "swagger-ui")]
1612 if self.docs_enabled {
1613 if let Some(path) = self.docs_path {
1614 app = app.docs(&path);
1615 }
1616 }
1617
1618 app.layers.extend(self.layers);
1621
1622 app
1623 }
1624
1625 pub async fn run(
1627 self,
1628 addr: impl AsRef<str>,
1629 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1630 self.build().run(addr.as_ref()).await
1631 }
1632}