1use crate::error::Result;
4use crate::interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor};
5use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT};
6use crate::response::IntoResponse;
7use crate::router::{MethodRouter, Router};
8use crate::server::Server;
9use std::collections::HashMap;
10use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
11
12pub struct RustApi {
30 router: Router,
31 openapi_spec: rustapi_openapi::OpenApiSpec,
32 layers: LayerStack,
33 body_limit: Option<usize>,
34 interceptors: InterceptorChain,
35}
36
37impl RustApi {
38 pub fn new() -> Self {
40 let _ = tracing_subscriber::registry()
42 .with(
43 EnvFilter::try_from_default_env()
44 .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
45 )
46 .with(tracing_subscriber::fmt::layer())
47 .try_init();
48
49 Self {
50 router: Router::new(),
51 openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
52 .register::<rustapi_openapi::ErrorSchema>()
53 .register::<rustapi_openapi::ErrorBodySchema>()
54 .register::<rustapi_openapi::ValidationErrorSchema>()
55 .register::<rustapi_openapi::ValidationErrorBodySchema>()
56 .register::<rustapi_openapi::FieldErrorSchema>(),
57 layers: LayerStack::new(),
58 body_limit: Some(DEFAULT_BODY_LIMIT), interceptors: InterceptorChain::new(),
60 }
61 }
62
63 #[cfg(feature = "swagger-ui")]
87 pub fn auto() -> Self {
88 Self::new().mount_auto_routes_grouped().docs("/docs")
90 }
91
92 #[cfg(not(feature = "swagger-ui"))]
97 pub fn auto() -> Self {
98 Self::new().mount_auto_routes_grouped()
99 }
100
101 pub fn config() -> RustApiConfig {
119 RustApiConfig::new()
120 }
121
122 pub fn body_limit(mut self, limit: usize) -> Self {
143 self.body_limit = Some(limit);
144 self
145 }
146
147 pub fn no_body_limit(mut self) -> Self {
160 self.body_limit = None;
161 self
162 }
163
164 pub fn layer<L>(mut self, layer: L) -> Self
184 where
185 L: MiddlewareLayer,
186 {
187 self.layers.push(Box::new(layer));
188 self
189 }
190
191 pub fn request_interceptor<I>(mut self, interceptor: I) -> Self
223 where
224 I: RequestInterceptor,
225 {
226 self.interceptors.add_request_interceptor(interceptor);
227 self
228 }
229
230 pub fn response_interceptor<I>(mut self, interceptor: I) -> Self
262 where
263 I: ResponseInterceptor,
264 {
265 self.interceptors.add_response_interceptor(interceptor);
266 self
267 }
268
269 pub fn state<S>(self, _state: S) -> Self
285 where
286 S: Clone + Send + Sync + 'static,
287 {
288 let state = _state;
290 let mut app = self;
291 app.router = app.router.state(state);
292 app
293 }
294
295 pub fn register_schema<T: for<'a> rustapi_openapi::Schema<'a>>(mut self) -> Self {
307 self.openapi_spec = self.openapi_spec.register::<T>();
308 self
309 }
310
311 pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
313 self.openapi_spec.info.title = title.to_string();
316 self.openapi_spec.info.version = version.to_string();
317 self.openapi_spec.info.description = description.map(|d| d.to_string());
318 self
319 }
320
321 pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
323 &self.openapi_spec
324 }
325
326 fn mount_auto_routes_grouped(mut self) -> Self {
327 let routes = crate::auto_route::collect_auto_routes();
328 let mut by_path: HashMap<String, MethodRouter> = HashMap::new();
329
330 for route in routes {
331 let method_enum = match route.method {
332 "GET" => http::Method::GET,
333 "POST" => http::Method::POST,
334 "PUT" => http::Method::PUT,
335 "DELETE" => http::Method::DELETE,
336 "PATCH" => http::Method::PATCH,
337 _ => http::Method::GET,
338 };
339
340 let path = if route.path.starts_with('/') {
341 route.path.to_string()
342 } else {
343 format!("/{}", route.path)
344 };
345
346 let entry = by_path.entry(path).or_default();
347 entry.insert_boxed_with_operation(method_enum, route.handler, route.operation);
348 }
349
350 #[cfg(feature = "tracing")]
351 let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
352 #[cfg(feature = "tracing")]
353 let path_count = by_path.len();
354
355 for (path, method_router) in by_path {
356 self = self.route(&path, method_router);
357 }
358
359 crate::trace_info!(
360 paths = path_count,
361 routes = route_count,
362 "Auto-registered routes"
363 );
364
365 crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
367
368 self
369 }
370
371 pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
382 for (method, op) in &method_router.operations {
384 let mut op = op.clone();
385 add_path_params_to_operation(path, &mut op);
386 self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
387 }
388
389 self.router = self.router.route(path, method_router);
390 self
391 }
392
393 pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
395 self.route(P::PATH, method_router)
396 }
397
398 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
402 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
403 self.route(path, method_router)
404 }
405
406 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
424 let method_enum = match route.method {
425 "GET" => http::Method::GET,
426 "POST" => http::Method::POST,
427 "PUT" => http::Method::PUT,
428 "DELETE" => http::Method::DELETE,
429 "PATCH" => http::Method::PATCH,
430 _ => http::Method::GET,
431 };
432
433 let mut op = route.operation;
435 add_path_params_to_operation(route.path, &mut op);
436 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
437
438 self.route_with_method(route.path, method_enum, route.handler)
439 }
440
441 fn route_with_method(
443 self,
444 path: &str,
445 method: http::Method,
446 handler: crate::handler::BoxedHandler,
447 ) -> Self {
448 use crate::router::MethodRouter;
449 let path = if !path.starts_with('/') {
458 format!("/{}", path)
459 } else {
460 path.to_string()
461 };
462
463 let mut handlers = std::collections::HashMap::new();
472 handlers.insert(method, handler);
473
474 let method_router = MethodRouter::from_boxed(handlers);
475 self.route(&path, method_router)
476 }
477
478 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
494 let normalized_prefix = normalize_prefix_for_openapi(prefix);
496
497 for (matchit_path, method_router) in router.method_routers() {
500 let display_path = router
502 .registered_routes()
503 .get(matchit_path)
504 .map(|info| info.path.clone())
505 .unwrap_or_else(|| matchit_path.clone());
506
507 let prefixed_path = if display_path == "/" {
509 normalized_prefix.clone()
510 } else {
511 format!("{}{}", normalized_prefix, display_path)
512 };
513
514 for (method, op) in &method_router.operations {
516 let mut op = op.clone();
517 add_path_params_to_operation(&prefixed_path, &mut op);
518 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
519 }
520 }
521
522 self.router = self.router.nest(prefix, router);
524 self
525 }
526
527 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
556 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
557 }
558
559 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
576 use crate::router::MethodRouter;
577 use std::collections::HashMap;
578
579 let prefix = config.prefix.clone();
580 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
581
582 let handler: crate::handler::BoxedHandler =
584 std::sync::Arc::new(move |req: crate::Request| {
585 let config = config.clone();
586 let path = req.uri().path().to_string();
587
588 Box::pin(async move {
589 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
590
591 match crate::static_files::StaticFile::serve(relative_path, &config).await {
592 Ok(response) => response,
593 Err(err) => err.into_response(),
594 }
595 })
596 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
597 });
598
599 let mut handlers = HashMap::new();
600 handlers.insert(http::Method::GET, handler);
601 let method_router = MethodRouter::from_boxed(handlers);
602
603 self.route(&catch_all_path, method_router)
604 }
605
606 #[cfg(feature = "compression")]
623 pub fn compression(self) -> Self {
624 self.layer(crate::middleware::CompressionLayer::new())
625 }
626
627 #[cfg(feature = "compression")]
643 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
644 self.layer(crate::middleware::CompressionLayer::with_config(config))
645 }
646
647 #[cfg(feature = "swagger-ui")]
671 pub fn docs(self, path: &str) -> Self {
672 let title = self.openapi_spec.info.title.clone();
673 let version = self.openapi_spec.info.version.clone();
674 let description = self.openapi_spec.info.description.clone();
675
676 self.docs_with_info(path, &title, &version, description.as_deref())
677 }
678
679 #[cfg(feature = "swagger-ui")]
688 pub fn docs_with_info(
689 mut self,
690 path: &str,
691 title: &str,
692 version: &str,
693 description: Option<&str>,
694 ) -> Self {
695 use crate::router::get;
696 self.openapi_spec.info.title = title.to_string();
698 self.openapi_spec.info.version = version.to_string();
699 if let Some(desc) = description {
700 self.openapi_spec.info.description = Some(desc.to_string());
701 }
702
703 let path = path.trim_end_matches('/');
704 let openapi_path = format!("{}/openapi.json", path);
705
706 let spec_json =
708 serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
709 let openapi_url = openapi_path.clone();
710
711 let spec_handler = move || {
713 let json = spec_json.clone();
714 async move {
715 http::Response::builder()
716 .status(http::StatusCode::OK)
717 .header(http::header::CONTENT_TYPE, "application/json")
718 .body(http_body_util::Full::new(bytes::Bytes::from(json)))
719 .unwrap()
720 }
721 };
722
723 let docs_handler = move || {
725 let url = openapi_url.clone();
726 async move { rustapi_openapi::swagger_ui_html(&url) }
727 };
728
729 self.route(&openapi_path, get(spec_handler))
730 .route(path, get(docs_handler))
731 }
732
733 #[cfg(feature = "swagger-ui")]
749 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
750 let title = self.openapi_spec.info.title.clone();
751 let version = self.openapi_spec.info.version.clone();
752 let description = self.openapi_spec.info.description.clone();
753
754 self.docs_with_auth_and_info(
755 path,
756 username,
757 password,
758 &title,
759 &version,
760 description.as_deref(),
761 )
762 }
763
764 #[cfg(feature = "swagger-ui")]
780 pub fn docs_with_auth_and_info(
781 mut self,
782 path: &str,
783 username: &str,
784 password: &str,
785 title: &str,
786 version: &str,
787 description: Option<&str>,
788 ) -> Self {
789 use crate::router::MethodRouter;
790 use base64::{engine::general_purpose::STANDARD, Engine};
791 use std::collections::HashMap;
792
793 self.openapi_spec.info.title = title.to_string();
795 self.openapi_spec.info.version = version.to_string();
796 if let Some(desc) = description {
797 self.openapi_spec.info.description = Some(desc.to_string());
798 }
799
800 let path = path.trim_end_matches('/');
801 let openapi_path = format!("{}/openapi.json", path);
802
803 let credentials = format!("{}:{}", username, password);
805 let encoded = STANDARD.encode(credentials.as_bytes());
806 let expected_auth = format!("Basic {}", encoded);
807
808 let spec_json =
810 serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
811 let openapi_url = openapi_path.clone();
812 let expected_auth_spec = expected_auth.clone();
813 let expected_auth_docs = expected_auth;
814
815 let spec_handler: crate::handler::BoxedHandler =
817 std::sync::Arc::new(move |req: crate::Request| {
818 let json = spec_json.clone();
819 let expected = expected_auth_spec.clone();
820 Box::pin(async move {
821 if !check_basic_auth(&req, &expected) {
822 return unauthorized_response();
823 }
824 http::Response::builder()
825 .status(http::StatusCode::OK)
826 .header(http::header::CONTENT_TYPE, "application/json")
827 .body(http_body_util::Full::new(bytes::Bytes::from(json)))
828 .unwrap()
829 })
830 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
831 });
832
833 let docs_handler: crate::handler::BoxedHandler =
835 std::sync::Arc::new(move |req: crate::Request| {
836 let url = openapi_url.clone();
837 let expected = expected_auth_docs.clone();
838 Box::pin(async move {
839 if !check_basic_auth(&req, &expected) {
840 return unauthorized_response();
841 }
842 rustapi_openapi::swagger_ui_html(&url)
843 })
844 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
845 });
846
847 let mut spec_handlers = HashMap::new();
849 spec_handlers.insert(http::Method::GET, spec_handler);
850 let spec_router = MethodRouter::from_boxed(spec_handlers);
851
852 let mut docs_handlers = HashMap::new();
853 docs_handlers.insert(http::Method::GET, docs_handler);
854 let docs_router = MethodRouter::from_boxed(docs_handlers);
855
856 self.route(&openapi_path, spec_router)
857 .route(path, docs_router)
858 }
859
860 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
871 if let Some(limit) = self.body_limit {
873 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
875 }
876
877 let server = Server::new(self.router, self.layers, self.interceptors);
878 server.run(addr).await
879 }
880
881 pub fn into_router(self) -> Router {
883 self.router
884 }
885
886 pub fn layers(&self) -> &LayerStack {
888 &self.layers
889 }
890
891 pub fn interceptors(&self) -> &InterceptorChain {
893 &self.interceptors
894 }
895}
896
897fn add_path_params_to_operation(path: &str, op: &mut rustapi_openapi::Operation) {
898 let mut params: Vec<String> = Vec::new();
899 let mut in_brace = false;
900 let mut current = String::new();
901
902 for ch in path.chars() {
903 match ch {
904 '{' => {
905 in_brace = true;
906 current.clear();
907 }
908 '}' => {
909 if in_brace {
910 in_brace = false;
911 if !current.is_empty() {
912 params.push(current.clone());
913 }
914 }
915 }
916 _ => {
917 if in_brace {
918 current.push(ch);
919 }
920 }
921 }
922 }
923
924 if params.is_empty() {
925 return;
926 }
927
928 let op_params = op.parameters.get_or_insert_with(Vec::new);
929
930 for name in params {
931 let already = op_params
932 .iter()
933 .any(|p| p.location == "path" && p.name == name);
934 if already {
935 continue;
936 }
937
938 let schema = infer_path_param_schema(&name);
940
941 op_params.push(rustapi_openapi::Parameter {
942 name,
943 location: "path".to_string(),
944 required: true,
945 description: None,
946 schema,
947 });
948 }
949}
950
951fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
960 let lower = name.to_lowercase();
961
962 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
964
965 if is_uuid {
966 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
967 "type": "string",
968 "format": "uuid"
969 }));
970 }
971
972 let is_integer = lower == "id"
974 || lower.ends_with("_id")
975 || (lower.ends_with("id") && lower.len() > 2) || lower == "page"
977 || lower == "limit"
978 || lower == "offset"
979 || lower == "count"
980 || lower.ends_with("_count")
981 || lower.ends_with("_num")
982 || lower == "year"
983 || lower == "month"
984 || lower == "day"
985 || lower == "index"
986 || lower == "position";
987
988 if is_integer {
989 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
990 "type": "integer",
991 "format": "int64"
992 }))
993 } else {
994 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
995 }
996}
997
998fn normalize_prefix_for_openapi(prefix: &str) -> String {
1005 if prefix.is_empty() {
1007 return "/".to_string();
1008 }
1009
1010 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
1012
1013 if segments.is_empty() {
1015 return "/".to_string();
1016 }
1017
1018 let mut result = String::with_capacity(prefix.len() + 1);
1020 for segment in segments {
1021 result.push('/');
1022 result.push_str(segment);
1023 }
1024
1025 result
1026}
1027
1028impl Default for RustApi {
1029 fn default() -> Self {
1030 Self::new()
1031 }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::RustApi;
1037 use crate::extract::{FromRequestParts, State};
1038 use crate::path_params::PathParams;
1039 use crate::request::Request;
1040 use crate::router::{get, post, Router};
1041 use bytes::Bytes;
1042 use http::Method;
1043 use proptest::prelude::*;
1044
1045 #[test]
1046 fn state_is_available_via_extractor() {
1047 let app = RustApi::new().state(123u32);
1048 let router = app.into_router();
1049
1050 let req = http::Request::builder()
1051 .method(Method::GET)
1052 .uri("/test")
1053 .body(())
1054 .unwrap();
1055 let (parts, _) = req.into_parts();
1056
1057 let request = Request::new(
1058 parts,
1059 crate::request::BodyVariant::Buffered(Bytes::new()),
1060 router.state_ref(),
1061 PathParams::new(),
1062 );
1063 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
1064 assert_eq!(value, 123u32);
1065 }
1066
1067 #[test]
1068 fn test_path_param_type_inference_integer() {
1069 use super::infer_path_param_schema;
1070
1071 let int_params = [
1073 "id",
1074 "user_id",
1075 "userId",
1076 "postId",
1077 "page",
1078 "limit",
1079 "offset",
1080 "count",
1081 "item_count",
1082 "year",
1083 "month",
1084 "day",
1085 "index",
1086 "position",
1087 ];
1088
1089 for name in int_params {
1090 let schema = infer_path_param_schema(name);
1091 match schema {
1092 rustapi_openapi::SchemaRef::Inline(v) => {
1093 assert_eq!(
1094 v.get("type").and_then(|v| v.as_str()),
1095 Some("integer"),
1096 "Expected '{}' to be inferred as integer",
1097 name
1098 );
1099 }
1100 _ => panic!("Expected inline schema for '{}'", name),
1101 }
1102 }
1103 }
1104
1105 #[test]
1106 fn test_path_param_type_inference_uuid() {
1107 use super::infer_path_param_schema;
1108
1109 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
1111
1112 for name in uuid_params {
1113 let schema = infer_path_param_schema(name);
1114 match schema {
1115 rustapi_openapi::SchemaRef::Inline(v) => {
1116 assert_eq!(
1117 v.get("type").and_then(|v| v.as_str()),
1118 Some("string"),
1119 "Expected '{}' to be inferred as string",
1120 name
1121 );
1122 assert_eq!(
1123 v.get("format").and_then(|v| v.as_str()),
1124 Some("uuid"),
1125 "Expected '{}' to have uuid format",
1126 name
1127 );
1128 }
1129 _ => panic!("Expected inline schema for '{}'", name),
1130 }
1131 }
1132 }
1133
1134 #[test]
1135 fn test_path_param_type_inference_string() {
1136 use super::infer_path_param_schema;
1137
1138 let string_params = ["name", "slug", "code", "token", "username"];
1140
1141 for name in string_params {
1142 let schema = infer_path_param_schema(name);
1143 match schema {
1144 rustapi_openapi::SchemaRef::Inline(v) => {
1145 assert_eq!(
1146 v.get("type").and_then(|v| v.as_str()),
1147 Some("string"),
1148 "Expected '{}' to be inferred as string",
1149 name
1150 );
1151 assert!(
1152 v.get("format").is_none()
1153 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
1154 "Expected '{}' to NOT have uuid format",
1155 name
1156 );
1157 }
1158 _ => panic!("Expected inline schema for '{}'", name),
1159 }
1160 }
1161 }
1162
1163 proptest! {
1170 #![proptest_config(ProptestConfig::with_cases(100))]
1171
1172 #[test]
1177 fn prop_nested_routes_in_openapi_spec(
1178 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1180 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1182 has_param in any::<bool>(),
1183 ) {
1184 async fn handler() -> &'static str { "handler" }
1185
1186 let prefix = format!("/{}", prefix_segments.join("/"));
1188
1189 let mut route_path = format!("/{}", route_segments.join("/"));
1191 if has_param {
1192 route_path.push_str("/{id}");
1193 }
1194
1195 let nested_router = Router::new().route(&route_path, get(handler));
1197 let app = RustApi::new().nest(&prefix, nested_router);
1198
1199 let expected_openapi_path = format!("{}{}", prefix, route_path);
1201
1202 let spec = app.openapi_spec();
1204
1205 prop_assert!(
1207 spec.paths.contains_key(&expected_openapi_path),
1208 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
1209 expected_openapi_path,
1210 spec.paths.keys().collect::<Vec<_>>()
1211 );
1212
1213 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1215 prop_assert!(
1216 path_item.get.is_some(),
1217 "GET operation should exist for path '{}'",
1218 expected_openapi_path
1219 );
1220 }
1221
1222 #[test]
1227 fn prop_multiple_methods_preserved_in_openapi(
1228 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1229 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1230 ) {
1231 async fn get_handler() -> &'static str { "get" }
1232 async fn post_handler() -> &'static str { "post" }
1233
1234 let prefix = format!("/{}", prefix_segments.join("/"));
1236 let route_path = format!("/{}", route_segments.join("/"));
1237
1238 let get_route_path = format!("{}/get", route_path);
1241 let post_route_path = format!("{}/post", route_path);
1242 let nested_router = Router::new()
1243 .route(&get_route_path, get(get_handler))
1244 .route(&post_route_path, post(post_handler));
1245 let app = RustApi::new().nest(&prefix, nested_router);
1246
1247 let expected_get_path = format!("{}{}", prefix, get_route_path);
1249 let expected_post_path = format!("{}{}", prefix, post_route_path);
1250
1251 let spec = app.openapi_spec();
1253
1254 prop_assert!(
1256 spec.paths.contains_key(&expected_get_path),
1257 "Expected OpenAPI path '{}' not found",
1258 expected_get_path
1259 );
1260 prop_assert!(
1261 spec.paths.contains_key(&expected_post_path),
1262 "Expected OpenAPI path '{}' not found",
1263 expected_post_path
1264 );
1265
1266 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
1268 prop_assert!(
1269 get_path_item.get.is_some(),
1270 "GET operation should exist for path '{}'",
1271 expected_get_path
1272 );
1273
1274 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
1276 prop_assert!(
1277 post_path_item.post.is_some(),
1278 "POST operation should exist for path '{}'",
1279 expected_post_path
1280 );
1281 }
1282
1283 #[test]
1288 fn prop_path_params_in_openapi_after_nesting(
1289 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1290 param_name in "[a-z][a-z0-9]{0,5}",
1291 ) {
1292 async fn handler() -> &'static str { "handler" }
1293
1294 let prefix = format!("/{}", prefix_segments.join("/"));
1296 let route_path = format!("/{{{}}}", param_name);
1297
1298 let nested_router = Router::new().route(&route_path, get(handler));
1300 let app = RustApi::new().nest(&prefix, nested_router);
1301
1302 let expected_openapi_path = format!("{}{}", prefix, route_path);
1304
1305 let spec = app.openapi_spec();
1307
1308 prop_assert!(
1310 spec.paths.contains_key(&expected_openapi_path),
1311 "Expected OpenAPI path '{}' not found",
1312 expected_openapi_path
1313 );
1314
1315 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1317 let get_op = path_item.get.as_ref().unwrap();
1318
1319 prop_assert!(
1320 get_op.parameters.is_some(),
1321 "Operation should have parameters for path '{}'",
1322 expected_openapi_path
1323 );
1324
1325 let params = get_op.parameters.as_ref().unwrap();
1326 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
1327 prop_assert!(
1328 has_param,
1329 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
1330 param_name,
1331 params.iter().map(|p| &p.name).collect::<Vec<_>>()
1332 );
1333 }
1334 }
1335
1336 proptest! {
1344 #![proptest_config(ProptestConfig::with_cases(100))]
1345
1346 #[test]
1351 fn prop_rustapi_nest_delegates_to_router_nest(
1352 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1353 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1354 has_param in any::<bool>(),
1355 ) {
1356 async fn handler() -> &'static str { "handler" }
1357
1358 let prefix = format!("/{}", prefix_segments.join("/"));
1360
1361 let mut route_path = format!("/{}", route_segments.join("/"));
1363 if has_param {
1364 route_path.push_str("/{id}");
1365 }
1366
1367 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1369 let nested_router_for_router = Router::new().route(&route_path, get(handler));
1370
1371 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1373 let rustapi_router = rustapi_app.into_router();
1374
1375 let router_app = Router::new().nest(&prefix, nested_router_for_router);
1377
1378 let rustapi_routes = rustapi_router.registered_routes();
1380 let router_routes = router_app.registered_routes();
1381
1382 prop_assert_eq!(
1383 rustapi_routes.len(),
1384 router_routes.len(),
1385 "RustApi and Router should have same number of routes"
1386 );
1387
1388 for (path, info) in router_routes {
1390 prop_assert!(
1391 rustapi_routes.contains_key(path),
1392 "Route '{}' from Router should exist in RustApi routes",
1393 path
1394 );
1395
1396 let rustapi_info = rustapi_routes.get(path).unwrap();
1397 prop_assert_eq!(
1398 &info.path, &rustapi_info.path,
1399 "Display paths should match for route '{}'",
1400 path
1401 );
1402 prop_assert_eq!(
1403 info.methods.len(), rustapi_info.methods.len(),
1404 "Method count should match for route '{}'",
1405 path
1406 );
1407 }
1408 }
1409
1410 #[test]
1415 fn prop_rustapi_nest_includes_routes_in_openapi(
1416 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1417 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1418 has_param in any::<bool>(),
1419 ) {
1420 async fn handler() -> &'static str { "handler" }
1421
1422 let prefix = format!("/{}", prefix_segments.join("/"));
1424
1425 let mut route_path = format!("/{}", route_segments.join("/"));
1427 if has_param {
1428 route_path.push_str("/{id}");
1429 }
1430
1431 let nested_router = Router::new().route(&route_path, get(handler));
1433 let app = RustApi::new().nest(&prefix, nested_router);
1434
1435 let expected_openapi_path = format!("{}{}", prefix, route_path);
1437
1438 let spec = app.openapi_spec();
1440
1441 prop_assert!(
1443 spec.paths.contains_key(&expected_openapi_path),
1444 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
1445 expected_openapi_path,
1446 spec.paths.keys().collect::<Vec<_>>()
1447 );
1448
1449 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1451 prop_assert!(
1452 path_item.get.is_some(),
1453 "GET operation should exist for path '{}'",
1454 expected_openapi_path
1455 );
1456 }
1457
1458 #[test]
1463 fn prop_rustapi_nest_route_matching_identical(
1464 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1465 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1466 param_value in "[a-z0-9]{1,10}",
1467 ) {
1468 use crate::router::RouteMatch;
1469
1470 async fn handler() -> &'static str { "handler" }
1471
1472 let prefix = format!("/{}", prefix_segments.join("/"));
1474 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
1475
1476 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1478 let nested_router_for_router = Router::new().route(&route_path, get(handler));
1479
1480 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1482 let rustapi_router = rustapi_app.into_router();
1483 let router_app = Router::new().nest(&prefix, nested_router_for_router);
1484
1485 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
1487
1488 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
1490 let router_match = router_app.match_route(&full_path, &Method::GET);
1491
1492 match (rustapi_match, router_match) {
1494 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
1495 prop_assert_eq!(
1496 rustapi_params.len(),
1497 router_params.len(),
1498 "Parameter count should match"
1499 );
1500 for (key, value) in &router_params {
1501 prop_assert!(
1502 rustapi_params.contains_key(key),
1503 "RustApi should have parameter '{}'",
1504 key
1505 );
1506 prop_assert_eq!(
1507 rustapi_params.get(key).unwrap(),
1508 value,
1509 "Parameter '{}' value should match",
1510 key
1511 );
1512 }
1513 }
1514 (rustapi_result, router_result) => {
1515 prop_assert!(
1516 false,
1517 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
1518 match rustapi_result {
1519 RouteMatch::Found { .. } => "Found",
1520 RouteMatch::NotFound => "NotFound",
1521 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
1522 },
1523 match router_result {
1524 RouteMatch::Found { .. } => "Found",
1525 RouteMatch::NotFound => "NotFound",
1526 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
1527 }
1528 );
1529 }
1530 }
1531 }
1532 }
1533
1534 #[test]
1536 fn test_openapi_operations_propagated_during_nesting() {
1537 async fn list_users() -> &'static str {
1538 "list users"
1539 }
1540 async fn get_user() -> &'static str {
1541 "get user"
1542 }
1543 async fn create_user() -> &'static str {
1544 "create user"
1545 }
1546
1547 let users_router = Router::new()
1550 .route("/", get(list_users))
1551 .route("/create", post(create_user))
1552 .route("/{id}", get(get_user));
1553
1554 let app = RustApi::new().nest("/api/v1/users", users_router);
1556
1557 let spec = app.openapi_spec();
1558
1559 assert!(
1561 spec.paths.contains_key("/api/v1/users"),
1562 "Should have /api/v1/users path"
1563 );
1564 let users_path = spec.paths.get("/api/v1/users").unwrap();
1565 assert!(users_path.get.is_some(), "Should have GET operation");
1566
1567 assert!(
1569 spec.paths.contains_key("/api/v1/users/create"),
1570 "Should have /api/v1/users/create path"
1571 );
1572 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
1573 assert!(create_path.post.is_some(), "Should have POST operation");
1574
1575 assert!(
1577 spec.paths.contains_key("/api/v1/users/{id}"),
1578 "Should have /api/v1/users/{{id}} path"
1579 );
1580 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
1581 assert!(
1582 user_path.get.is_some(),
1583 "Should have GET operation for user by id"
1584 );
1585
1586 let get_user_op = user_path.get.as_ref().unwrap();
1588 assert!(get_user_op.parameters.is_some(), "Should have parameters");
1589 let params = get_user_op.parameters.as_ref().unwrap();
1590 assert!(
1591 params
1592 .iter()
1593 .any(|p| p.name == "id" && p.location == "path"),
1594 "Should have 'id' path parameter"
1595 );
1596 }
1597
1598 #[test]
1600 fn test_openapi_spec_empty_without_routes() {
1601 let app = RustApi::new();
1602 let spec = app.openapi_spec();
1603
1604 assert!(
1606 spec.paths.is_empty(),
1607 "OpenAPI spec should have no paths without routes"
1608 );
1609 }
1610
1611 #[test]
1616 fn test_rustapi_nest_delegates_to_router_nest() {
1617 use crate::router::RouteMatch;
1618
1619 async fn list_users() -> &'static str {
1620 "list users"
1621 }
1622 async fn get_user() -> &'static str {
1623 "get user"
1624 }
1625 async fn create_user() -> &'static str {
1626 "create user"
1627 }
1628
1629 let users_router = Router::new()
1631 .route("/", get(list_users))
1632 .route("/create", post(create_user))
1633 .route("/{id}", get(get_user));
1634
1635 let app = RustApi::new().nest("/api/v1/users", users_router);
1637 let router = app.into_router();
1638
1639 let routes = router.registered_routes();
1641 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
1642
1643 assert!(
1645 routes.contains_key("/api/v1/users"),
1646 "Should have /api/v1/users route"
1647 );
1648 assert!(
1649 routes.contains_key("/api/v1/users/create"),
1650 "Should have /api/v1/users/create route"
1651 );
1652 assert!(
1653 routes.contains_key("/api/v1/users/:id"),
1654 "Should have /api/v1/users/:id route"
1655 );
1656
1657 match router.match_route("/api/v1/users", &Method::GET) {
1659 RouteMatch::Found { params, .. } => {
1660 assert!(params.is_empty(), "Root route should have no params");
1661 }
1662 _ => panic!("GET /api/v1/users should be found"),
1663 }
1664
1665 match router.match_route("/api/v1/users/create", &Method::POST) {
1666 RouteMatch::Found { params, .. } => {
1667 assert!(params.is_empty(), "Create route should have no params");
1668 }
1669 _ => panic!("POST /api/v1/users/create should be found"),
1670 }
1671
1672 match router.match_route("/api/v1/users/123", &Method::GET) {
1673 RouteMatch::Found { params, .. } => {
1674 assert_eq!(
1675 params.get("id"),
1676 Some(&"123".to_string()),
1677 "Should extract id param"
1678 );
1679 }
1680 _ => panic!("GET /api/v1/users/123 should be found"),
1681 }
1682
1683 match router.match_route("/api/v1/users", &Method::DELETE) {
1685 RouteMatch::MethodNotAllowed { allowed } => {
1686 assert!(allowed.contains(&Method::GET), "Should allow GET");
1687 }
1688 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
1689 }
1690 }
1691
1692 #[test]
1697 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
1698 async fn list_items() -> &'static str {
1699 "list items"
1700 }
1701 async fn get_item() -> &'static str {
1702 "get item"
1703 }
1704
1705 let items_router = Router::new()
1707 .route("/", get(list_items))
1708 .route("/{item_id}", get(get_item));
1709
1710 let app = RustApi::new().nest("/api/items", items_router);
1712
1713 let spec = app.openapi_spec();
1715
1716 assert!(
1718 spec.paths.contains_key("/api/items"),
1719 "Should have /api/items in OpenAPI"
1720 );
1721 assert!(
1722 spec.paths.contains_key("/api/items/{item_id}"),
1723 "Should have /api/items/{{item_id}} in OpenAPI"
1724 );
1725
1726 let list_path = spec.paths.get("/api/items").unwrap();
1728 assert!(
1729 list_path.get.is_some(),
1730 "Should have GET operation for /api/items"
1731 );
1732
1733 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
1734 assert!(
1735 get_path.get.is_some(),
1736 "Should have GET operation for /api/items/{{item_id}}"
1737 );
1738
1739 let get_op = get_path.get.as_ref().unwrap();
1741 assert!(get_op.parameters.is_some(), "Should have parameters");
1742 let params = get_op.parameters.as_ref().unwrap();
1743 assert!(
1744 params
1745 .iter()
1746 .any(|p| p.name == "item_id" && p.location == "path"),
1747 "Should have 'item_id' path parameter"
1748 );
1749 }
1750}
1751
1752#[cfg(feature = "swagger-ui")]
1754fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1755 req.headers()
1756 .get(http::header::AUTHORIZATION)
1757 .and_then(|v| v.to_str().ok())
1758 .map(|auth| auth == expected)
1759 .unwrap_or(false)
1760}
1761
1762#[cfg(feature = "swagger-ui")]
1764fn unauthorized_response() -> crate::Response {
1765 http::Response::builder()
1766 .status(http::StatusCode::UNAUTHORIZED)
1767 .header(
1768 http::header::WWW_AUTHENTICATE,
1769 "Basic realm=\"API Documentation\"",
1770 )
1771 .header(http::header::CONTENT_TYPE, "text/plain")
1772 .body(http_body_util::Full::new(bytes::Bytes::from(
1773 "Unauthorized",
1774 )))
1775 .unwrap()
1776}
1777
1778pub struct RustApiConfig {
1780 docs_path: Option<String>,
1781 docs_enabled: bool,
1782 api_title: String,
1783 api_version: String,
1784 api_description: Option<String>,
1785 body_limit: Option<usize>,
1786 layers: LayerStack,
1787}
1788
1789impl Default for RustApiConfig {
1790 fn default() -> Self {
1791 Self::new()
1792 }
1793}
1794
1795impl RustApiConfig {
1796 pub fn new() -> Self {
1797 Self {
1798 docs_path: Some("/docs".to_string()),
1799 docs_enabled: true,
1800 api_title: "RustAPI".to_string(),
1801 api_version: "1.0.0".to_string(),
1802 api_description: None,
1803 body_limit: None,
1804 layers: LayerStack::new(),
1805 }
1806 }
1807
1808 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
1810 self.docs_path = Some(path.into());
1811 self
1812 }
1813
1814 pub fn docs_enabled(mut self, enabled: bool) -> Self {
1816 self.docs_enabled = enabled;
1817 self
1818 }
1819
1820 pub fn openapi_info(
1822 mut self,
1823 title: impl Into<String>,
1824 version: impl Into<String>,
1825 description: Option<impl Into<String>>,
1826 ) -> Self {
1827 self.api_title = title.into();
1828 self.api_version = version.into();
1829 self.api_description = description.map(|d| d.into());
1830 self
1831 }
1832
1833 pub fn body_limit(mut self, limit: usize) -> Self {
1835 self.body_limit = Some(limit);
1836 self
1837 }
1838
1839 pub fn layer<L>(mut self, layer: L) -> Self
1841 where
1842 L: MiddlewareLayer,
1843 {
1844 self.layers.push(Box::new(layer));
1845 self
1846 }
1847
1848 pub fn build(self) -> RustApi {
1850 let mut app = RustApi::new().mount_auto_routes_grouped();
1851
1852 if let Some(limit) = self.body_limit {
1854 app = app.body_limit(limit);
1855 }
1856
1857 app = app.openapi_info(
1858 &self.api_title,
1859 &self.api_version,
1860 self.api_description.as_deref(),
1861 );
1862
1863 #[cfg(feature = "swagger-ui")]
1864 if self.docs_enabled {
1865 if let Some(path) = self.docs_path {
1866 app = app.docs(&path);
1867 }
1868 }
1869
1870 app.layers.extend(self.layers);
1873
1874 app
1875 }
1876
1877 pub async fn run(
1879 self,
1880 addr: impl AsRef<str>,
1881 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1882 self.build().run(addr.as_ref()).await
1883 }
1884}