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 #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
397 pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
398 self.route(path, method_router)
399 }
400
401 pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
419 let method_enum = match route.method {
420 "GET" => http::Method::GET,
421 "POST" => http::Method::POST,
422 "PUT" => http::Method::PUT,
423 "DELETE" => http::Method::DELETE,
424 "PATCH" => http::Method::PATCH,
425 _ => http::Method::GET,
426 };
427
428 let mut op = route.operation;
430 add_path_params_to_operation(route.path, &mut op);
431 self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
432
433 self.route_with_method(route.path, method_enum, route.handler)
434 }
435
436 fn route_with_method(
438 self,
439 path: &str,
440 method: http::Method,
441 handler: crate::handler::BoxedHandler,
442 ) -> Self {
443 use crate::router::MethodRouter;
444 let path = if !path.starts_with('/') {
453 format!("/{}", path)
454 } else {
455 path.to_string()
456 };
457
458 let mut handlers = std::collections::HashMap::new();
467 handlers.insert(method, handler);
468
469 let method_router = MethodRouter::from_boxed(handlers);
470 self.route(&path, method_router)
471 }
472
473 pub fn nest(mut self, prefix: &str, router: Router) -> Self {
489 let normalized_prefix = normalize_prefix_for_openapi(prefix);
491
492 for (matchit_path, method_router) in router.method_routers() {
495 let display_path = router
497 .registered_routes()
498 .get(matchit_path)
499 .map(|info| info.path.clone())
500 .unwrap_or_else(|| matchit_path.clone());
501
502 let prefixed_path = if display_path == "/" {
504 normalized_prefix.clone()
505 } else {
506 format!("{}{}", normalized_prefix, display_path)
507 };
508
509 for (method, op) in &method_router.operations {
511 let mut op = op.clone();
512 add_path_params_to_operation(&prefixed_path, &mut op);
513 self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
514 }
515 }
516
517 self.router = self.router.nest(prefix, router);
519 self
520 }
521
522 pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
551 self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
552 }
553
554 pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
571 use crate::router::MethodRouter;
572 use std::collections::HashMap;
573
574 let prefix = config.prefix.clone();
575 let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
576
577 let handler: crate::handler::BoxedHandler =
579 std::sync::Arc::new(move |req: crate::Request| {
580 let config = config.clone();
581 let path = req.uri().path().to_string();
582
583 Box::pin(async move {
584 let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
585
586 match crate::static_files::StaticFile::serve(relative_path, &config).await {
587 Ok(response) => response,
588 Err(err) => err.into_response(),
589 }
590 })
591 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
592 });
593
594 let mut handlers = HashMap::new();
595 handlers.insert(http::Method::GET, handler);
596 let method_router = MethodRouter::from_boxed(handlers);
597
598 self.route(&catch_all_path, method_router)
599 }
600
601 #[cfg(feature = "compression")]
618 pub fn compression(self) -> Self {
619 self.layer(crate::middleware::CompressionLayer::new())
620 }
621
622 #[cfg(feature = "compression")]
638 pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
639 self.layer(crate::middleware::CompressionLayer::with_config(config))
640 }
641
642 #[cfg(feature = "swagger-ui")]
666 pub fn docs(self, path: &str) -> Self {
667 let title = self.openapi_spec.info.title.clone();
668 let version = self.openapi_spec.info.version.clone();
669 let description = self.openapi_spec.info.description.clone();
670
671 self.docs_with_info(path, &title, &version, description.as_deref())
672 }
673
674 #[cfg(feature = "swagger-ui")]
683 pub fn docs_with_info(
684 mut self,
685 path: &str,
686 title: &str,
687 version: &str,
688 description: Option<&str>,
689 ) -> Self {
690 use crate::router::get;
691 self.openapi_spec.info.title = title.to_string();
693 self.openapi_spec.info.version = version.to_string();
694 if let Some(desc) = description {
695 self.openapi_spec.info.description = Some(desc.to_string());
696 }
697
698 let path = path.trim_end_matches('/');
699 let openapi_path = format!("{}/openapi.json", path);
700
701 let spec_json =
703 serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
704 let openapi_url = openapi_path.clone();
705
706 let spec_handler = move || {
708 let json = spec_json.clone();
709 async move {
710 http::Response::builder()
711 .status(http::StatusCode::OK)
712 .header(http::header::CONTENT_TYPE, "application/json")
713 .body(http_body_util::Full::new(bytes::Bytes::from(json)))
714 .unwrap()
715 }
716 };
717
718 let docs_handler = move || {
720 let url = openapi_url.clone();
721 async move { rustapi_openapi::swagger_ui_html(&url) }
722 };
723
724 self.route(&openapi_path, get(spec_handler))
725 .route(path, get(docs_handler))
726 }
727
728 #[cfg(feature = "swagger-ui")]
744 pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
745 let title = self.openapi_spec.info.title.clone();
746 let version = self.openapi_spec.info.version.clone();
747 let description = self.openapi_spec.info.description.clone();
748
749 self.docs_with_auth_and_info(
750 path,
751 username,
752 password,
753 &title,
754 &version,
755 description.as_deref(),
756 )
757 }
758
759 #[cfg(feature = "swagger-ui")]
775 pub fn docs_with_auth_and_info(
776 mut self,
777 path: &str,
778 username: &str,
779 password: &str,
780 title: &str,
781 version: &str,
782 description: Option<&str>,
783 ) -> Self {
784 use crate::router::MethodRouter;
785 use base64::{engine::general_purpose::STANDARD, Engine};
786 use std::collections::HashMap;
787
788 self.openapi_spec.info.title = title.to_string();
790 self.openapi_spec.info.version = version.to_string();
791 if let Some(desc) = description {
792 self.openapi_spec.info.description = Some(desc.to_string());
793 }
794
795 let path = path.trim_end_matches('/');
796 let openapi_path = format!("{}/openapi.json", path);
797
798 let credentials = format!("{}:{}", username, password);
800 let encoded = STANDARD.encode(credentials.as_bytes());
801 let expected_auth = format!("Basic {}", encoded);
802
803 let spec_json =
805 serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
806 let openapi_url = openapi_path.clone();
807 let expected_auth_spec = expected_auth.clone();
808 let expected_auth_docs = expected_auth;
809
810 let spec_handler: crate::handler::BoxedHandler =
812 std::sync::Arc::new(move |req: crate::Request| {
813 let json = spec_json.clone();
814 let expected = expected_auth_spec.clone();
815 Box::pin(async move {
816 if !check_basic_auth(&req, &expected) {
817 return unauthorized_response();
818 }
819 http::Response::builder()
820 .status(http::StatusCode::OK)
821 .header(http::header::CONTENT_TYPE, "application/json")
822 .body(http_body_util::Full::new(bytes::Bytes::from(json)))
823 .unwrap()
824 })
825 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
826 });
827
828 let docs_handler: crate::handler::BoxedHandler =
830 std::sync::Arc::new(move |req: crate::Request| {
831 let url = openapi_url.clone();
832 let expected = expected_auth_docs.clone();
833 Box::pin(async move {
834 if !check_basic_auth(&req, &expected) {
835 return unauthorized_response();
836 }
837 rustapi_openapi::swagger_ui_html(&url)
838 })
839 as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
840 });
841
842 let mut spec_handlers = HashMap::new();
844 spec_handlers.insert(http::Method::GET, spec_handler);
845 let spec_router = MethodRouter::from_boxed(spec_handlers);
846
847 let mut docs_handlers = HashMap::new();
848 docs_handlers.insert(http::Method::GET, docs_handler);
849 let docs_router = MethodRouter::from_boxed(docs_handlers);
850
851 self.route(&openapi_path, spec_router)
852 .route(path, docs_router)
853 }
854
855 pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
866 if let Some(limit) = self.body_limit {
868 self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
870 }
871
872 let server = Server::new(self.router, self.layers, self.interceptors);
873 server.run(addr).await
874 }
875
876 pub fn into_router(self) -> Router {
878 self.router
879 }
880
881 pub fn layers(&self) -> &LayerStack {
883 &self.layers
884 }
885
886 pub fn interceptors(&self) -> &InterceptorChain {
888 &self.interceptors
889 }
890}
891
892fn add_path_params_to_operation(path: &str, op: &mut rustapi_openapi::Operation) {
893 let mut params: Vec<String> = Vec::new();
894 let mut in_brace = false;
895 let mut current = String::new();
896
897 for ch in path.chars() {
898 match ch {
899 '{' => {
900 in_brace = true;
901 current.clear();
902 }
903 '}' => {
904 if in_brace {
905 in_brace = false;
906 if !current.is_empty() {
907 params.push(current.clone());
908 }
909 }
910 }
911 _ => {
912 if in_brace {
913 current.push(ch);
914 }
915 }
916 }
917 }
918
919 if params.is_empty() {
920 return;
921 }
922
923 let op_params = op.parameters.get_or_insert_with(Vec::new);
924
925 for name in params {
926 let already = op_params
927 .iter()
928 .any(|p| p.location == "path" && p.name == name);
929 if already {
930 continue;
931 }
932
933 let schema = infer_path_param_schema(&name);
935
936 op_params.push(rustapi_openapi::Parameter {
937 name,
938 location: "path".to_string(),
939 required: true,
940 description: None,
941 schema,
942 });
943 }
944}
945
946fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
955 let lower = name.to_lowercase();
956
957 let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
959
960 if is_uuid {
961 return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
962 "type": "string",
963 "format": "uuid"
964 }));
965 }
966
967 let is_integer = lower == "id"
969 || lower.ends_with("_id")
970 || (lower.ends_with("id") && lower.len() > 2) || lower == "page"
972 || lower == "limit"
973 || lower == "offset"
974 || lower == "count"
975 || lower.ends_with("_count")
976 || lower.ends_with("_num")
977 || lower == "year"
978 || lower == "month"
979 || lower == "day"
980 || lower == "index"
981 || lower == "position";
982
983 if is_integer {
984 rustapi_openapi::SchemaRef::Inline(serde_json::json!({
985 "type": "integer",
986 "format": "int64"
987 }))
988 } else {
989 rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
990 }
991}
992
993fn normalize_prefix_for_openapi(prefix: &str) -> String {
1000 if prefix.is_empty() {
1002 return "/".to_string();
1003 }
1004
1005 let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
1007
1008 if segments.is_empty() {
1010 return "/".to_string();
1011 }
1012
1013 let mut result = String::with_capacity(prefix.len() + 1);
1015 for segment in segments {
1016 result.push('/');
1017 result.push_str(segment);
1018 }
1019
1020 result
1021}
1022
1023impl Default for RustApi {
1024 fn default() -> Self {
1025 Self::new()
1026 }
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::RustApi;
1032 use crate::extract::{FromRequestParts, State};
1033 use crate::path_params::PathParams;
1034 use crate::request::Request;
1035 use crate::router::{get, post, Router};
1036 use bytes::Bytes;
1037 use http::Method;
1038 use proptest::prelude::*;
1039
1040 #[test]
1041 fn state_is_available_via_extractor() {
1042 let app = RustApi::new().state(123u32);
1043 let router = app.into_router();
1044
1045 let req = http::Request::builder()
1046 .method(Method::GET)
1047 .uri("/test")
1048 .body(())
1049 .unwrap();
1050 let (parts, _) = req.into_parts();
1051
1052 let request = Request::new(
1053 parts,
1054 crate::request::BodyVariant::Buffered(Bytes::new()),
1055 router.state_ref(),
1056 PathParams::new(),
1057 );
1058 let State(value) = State::<u32>::from_request_parts(&request).unwrap();
1059 assert_eq!(value, 123u32);
1060 }
1061
1062 #[test]
1063 fn test_path_param_type_inference_integer() {
1064 use super::infer_path_param_schema;
1065
1066 let int_params = [
1068 "id",
1069 "user_id",
1070 "userId",
1071 "postId",
1072 "page",
1073 "limit",
1074 "offset",
1075 "count",
1076 "item_count",
1077 "year",
1078 "month",
1079 "day",
1080 "index",
1081 "position",
1082 ];
1083
1084 for name in int_params {
1085 let schema = infer_path_param_schema(name);
1086 match schema {
1087 rustapi_openapi::SchemaRef::Inline(v) => {
1088 assert_eq!(
1089 v.get("type").and_then(|v| v.as_str()),
1090 Some("integer"),
1091 "Expected '{}' to be inferred as integer",
1092 name
1093 );
1094 }
1095 _ => panic!("Expected inline schema for '{}'", name),
1096 }
1097 }
1098 }
1099
1100 #[test]
1101 fn test_path_param_type_inference_uuid() {
1102 use super::infer_path_param_schema;
1103
1104 let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
1106
1107 for name in uuid_params {
1108 let schema = infer_path_param_schema(name);
1109 match schema {
1110 rustapi_openapi::SchemaRef::Inline(v) => {
1111 assert_eq!(
1112 v.get("type").and_then(|v| v.as_str()),
1113 Some("string"),
1114 "Expected '{}' to be inferred as string",
1115 name
1116 );
1117 assert_eq!(
1118 v.get("format").and_then(|v| v.as_str()),
1119 Some("uuid"),
1120 "Expected '{}' to have uuid format",
1121 name
1122 );
1123 }
1124 _ => panic!("Expected inline schema for '{}'", name),
1125 }
1126 }
1127 }
1128
1129 #[test]
1130 fn test_path_param_type_inference_string() {
1131 use super::infer_path_param_schema;
1132
1133 let string_params = ["name", "slug", "code", "token", "username"];
1135
1136 for name in string_params {
1137 let schema = infer_path_param_schema(name);
1138 match schema {
1139 rustapi_openapi::SchemaRef::Inline(v) => {
1140 assert_eq!(
1141 v.get("type").and_then(|v| v.as_str()),
1142 Some("string"),
1143 "Expected '{}' to be inferred as string",
1144 name
1145 );
1146 assert!(
1147 v.get("format").is_none()
1148 || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
1149 "Expected '{}' to NOT have uuid format",
1150 name
1151 );
1152 }
1153 _ => panic!("Expected inline schema for '{}'", name),
1154 }
1155 }
1156 }
1157
1158 proptest! {
1165 #![proptest_config(ProptestConfig::with_cases(100))]
1166
1167 #[test]
1172 fn prop_nested_routes_in_openapi_spec(
1173 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1175 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1177 has_param in any::<bool>(),
1178 ) {
1179 async fn handler() -> &'static str { "handler" }
1180
1181 let prefix = format!("/{}", prefix_segments.join("/"));
1183
1184 let mut route_path = format!("/{}", route_segments.join("/"));
1186 if has_param {
1187 route_path.push_str("/{id}");
1188 }
1189
1190 let nested_router = Router::new().route(&route_path, get(handler));
1192 let app = RustApi::new().nest(&prefix, nested_router);
1193
1194 let expected_openapi_path = format!("{}{}", prefix, route_path);
1196
1197 let spec = app.openapi_spec();
1199
1200 prop_assert!(
1202 spec.paths.contains_key(&expected_openapi_path),
1203 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
1204 expected_openapi_path,
1205 spec.paths.keys().collect::<Vec<_>>()
1206 );
1207
1208 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1210 prop_assert!(
1211 path_item.get.is_some(),
1212 "GET operation should exist for path '{}'",
1213 expected_openapi_path
1214 );
1215 }
1216
1217 #[test]
1222 fn prop_multiple_methods_preserved_in_openapi(
1223 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1224 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1225 ) {
1226 async fn get_handler() -> &'static str { "get" }
1227 async fn post_handler() -> &'static str { "post" }
1228
1229 let prefix = format!("/{}", prefix_segments.join("/"));
1231 let route_path = format!("/{}", route_segments.join("/"));
1232
1233 let get_route_path = format!("{}/get", route_path);
1236 let post_route_path = format!("{}/post", route_path);
1237 let nested_router = Router::new()
1238 .route(&get_route_path, get(get_handler))
1239 .route(&post_route_path, post(post_handler));
1240 let app = RustApi::new().nest(&prefix, nested_router);
1241
1242 let expected_get_path = format!("{}{}", prefix, get_route_path);
1244 let expected_post_path = format!("{}{}", prefix, post_route_path);
1245
1246 let spec = app.openapi_spec();
1248
1249 prop_assert!(
1251 spec.paths.contains_key(&expected_get_path),
1252 "Expected OpenAPI path '{}' not found",
1253 expected_get_path
1254 );
1255 prop_assert!(
1256 spec.paths.contains_key(&expected_post_path),
1257 "Expected OpenAPI path '{}' not found",
1258 expected_post_path
1259 );
1260
1261 let get_path_item = spec.paths.get(&expected_get_path).unwrap();
1263 prop_assert!(
1264 get_path_item.get.is_some(),
1265 "GET operation should exist for path '{}'",
1266 expected_get_path
1267 );
1268
1269 let post_path_item = spec.paths.get(&expected_post_path).unwrap();
1271 prop_assert!(
1272 post_path_item.post.is_some(),
1273 "POST operation should exist for path '{}'",
1274 expected_post_path
1275 );
1276 }
1277
1278 #[test]
1283 fn prop_path_params_in_openapi_after_nesting(
1284 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1285 param_name in "[a-z][a-z0-9]{0,5}",
1286 ) {
1287 async fn handler() -> &'static str { "handler" }
1288
1289 let prefix = format!("/{}", prefix_segments.join("/"));
1291 let route_path = format!("/{{{}}}", param_name);
1292
1293 let nested_router = Router::new().route(&route_path, get(handler));
1295 let app = RustApi::new().nest(&prefix, nested_router);
1296
1297 let expected_openapi_path = format!("{}{}", prefix, route_path);
1299
1300 let spec = app.openapi_spec();
1302
1303 prop_assert!(
1305 spec.paths.contains_key(&expected_openapi_path),
1306 "Expected OpenAPI path '{}' not found",
1307 expected_openapi_path
1308 );
1309
1310 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1312 let get_op = path_item.get.as_ref().unwrap();
1313
1314 prop_assert!(
1315 get_op.parameters.is_some(),
1316 "Operation should have parameters for path '{}'",
1317 expected_openapi_path
1318 );
1319
1320 let params = get_op.parameters.as_ref().unwrap();
1321 let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
1322 prop_assert!(
1323 has_param,
1324 "Path parameter '{}' should exist in operation parameters. Found: {:?}",
1325 param_name,
1326 params.iter().map(|p| &p.name).collect::<Vec<_>>()
1327 );
1328 }
1329 }
1330
1331 proptest! {
1339 #![proptest_config(ProptestConfig::with_cases(100))]
1340
1341 #[test]
1346 fn prop_rustapi_nest_delegates_to_router_nest(
1347 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1348 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1349 has_param in any::<bool>(),
1350 ) {
1351 async fn handler() -> &'static str { "handler" }
1352
1353 let prefix = format!("/{}", prefix_segments.join("/"));
1355
1356 let mut route_path = format!("/{}", route_segments.join("/"));
1358 if has_param {
1359 route_path.push_str("/{id}");
1360 }
1361
1362 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1364 let nested_router_for_router = Router::new().route(&route_path, get(handler));
1365
1366 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1368 let rustapi_router = rustapi_app.into_router();
1369
1370 let router_app = Router::new().nest(&prefix, nested_router_for_router);
1372
1373 let rustapi_routes = rustapi_router.registered_routes();
1375 let router_routes = router_app.registered_routes();
1376
1377 prop_assert_eq!(
1378 rustapi_routes.len(),
1379 router_routes.len(),
1380 "RustApi and Router should have same number of routes"
1381 );
1382
1383 for (path, info) in router_routes {
1385 prop_assert!(
1386 rustapi_routes.contains_key(path),
1387 "Route '{}' from Router should exist in RustApi routes",
1388 path
1389 );
1390
1391 let rustapi_info = rustapi_routes.get(path).unwrap();
1392 prop_assert_eq!(
1393 &info.path, &rustapi_info.path,
1394 "Display paths should match for route '{}'",
1395 path
1396 );
1397 prop_assert_eq!(
1398 info.methods.len(), rustapi_info.methods.len(),
1399 "Method count should match for route '{}'",
1400 path
1401 );
1402 }
1403 }
1404
1405 #[test]
1410 fn prop_rustapi_nest_includes_routes_in_openapi(
1411 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1412 route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1413 has_param in any::<bool>(),
1414 ) {
1415 async fn handler() -> &'static str { "handler" }
1416
1417 let prefix = format!("/{}", prefix_segments.join("/"));
1419
1420 let mut route_path = format!("/{}", route_segments.join("/"));
1422 if has_param {
1423 route_path.push_str("/{id}");
1424 }
1425
1426 let nested_router = Router::new().route(&route_path, get(handler));
1428 let app = RustApi::new().nest(&prefix, nested_router);
1429
1430 let expected_openapi_path = format!("{}{}", prefix, route_path);
1432
1433 let spec = app.openapi_spec();
1435
1436 prop_assert!(
1438 spec.paths.contains_key(&expected_openapi_path),
1439 "Expected OpenAPI path '{}' not found. Available paths: {:?}",
1440 expected_openapi_path,
1441 spec.paths.keys().collect::<Vec<_>>()
1442 );
1443
1444 let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1446 prop_assert!(
1447 path_item.get.is_some(),
1448 "GET operation should exist for path '{}'",
1449 expected_openapi_path
1450 );
1451 }
1452
1453 #[test]
1458 fn prop_rustapi_nest_route_matching_identical(
1459 prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1460 route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1461 param_value in "[a-z0-9]{1,10}",
1462 ) {
1463 use crate::router::RouteMatch;
1464
1465 async fn handler() -> &'static str { "handler" }
1466
1467 let prefix = format!("/{}", prefix_segments.join("/"));
1469 let route_path = format!("/{}/{{id}}", route_segments.join("/"));
1470
1471 let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1473 let nested_router_for_router = Router::new().route(&route_path, get(handler));
1474
1475 let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1477 let rustapi_router = rustapi_app.into_router();
1478 let router_app = Router::new().nest(&prefix, nested_router_for_router);
1479
1480 let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
1482
1483 let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
1485 let router_match = router_app.match_route(&full_path, &Method::GET);
1486
1487 match (rustapi_match, router_match) {
1489 (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
1490 prop_assert_eq!(
1491 rustapi_params.len(),
1492 router_params.len(),
1493 "Parameter count should match"
1494 );
1495 for (key, value) in &router_params {
1496 prop_assert!(
1497 rustapi_params.contains_key(key),
1498 "RustApi should have parameter '{}'",
1499 key
1500 );
1501 prop_assert_eq!(
1502 rustapi_params.get(key).unwrap(),
1503 value,
1504 "Parameter '{}' value should match",
1505 key
1506 );
1507 }
1508 }
1509 (rustapi_result, router_result) => {
1510 prop_assert!(
1511 false,
1512 "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
1513 match rustapi_result {
1514 RouteMatch::Found { .. } => "Found",
1515 RouteMatch::NotFound => "NotFound",
1516 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
1517 },
1518 match router_result {
1519 RouteMatch::Found { .. } => "Found",
1520 RouteMatch::NotFound => "NotFound",
1521 RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
1522 }
1523 );
1524 }
1525 }
1526 }
1527 }
1528
1529 #[test]
1531 fn test_openapi_operations_propagated_during_nesting() {
1532 async fn list_users() -> &'static str {
1533 "list users"
1534 }
1535 async fn get_user() -> &'static str {
1536 "get user"
1537 }
1538 async fn create_user() -> &'static str {
1539 "create user"
1540 }
1541
1542 let users_router = Router::new()
1545 .route("/", get(list_users))
1546 .route("/create", post(create_user))
1547 .route("/{id}", get(get_user));
1548
1549 let app = RustApi::new().nest("/api/v1/users", users_router);
1551
1552 let spec = app.openapi_spec();
1553
1554 assert!(
1556 spec.paths.contains_key("/api/v1/users"),
1557 "Should have /api/v1/users path"
1558 );
1559 let users_path = spec.paths.get("/api/v1/users").unwrap();
1560 assert!(users_path.get.is_some(), "Should have GET operation");
1561
1562 assert!(
1564 spec.paths.contains_key("/api/v1/users/create"),
1565 "Should have /api/v1/users/create path"
1566 );
1567 let create_path = spec.paths.get("/api/v1/users/create").unwrap();
1568 assert!(create_path.post.is_some(), "Should have POST operation");
1569
1570 assert!(
1572 spec.paths.contains_key("/api/v1/users/{id}"),
1573 "Should have /api/v1/users/{{id}} path"
1574 );
1575 let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
1576 assert!(
1577 user_path.get.is_some(),
1578 "Should have GET operation for user by id"
1579 );
1580
1581 let get_user_op = user_path.get.as_ref().unwrap();
1583 assert!(get_user_op.parameters.is_some(), "Should have parameters");
1584 let params = get_user_op.parameters.as_ref().unwrap();
1585 assert!(
1586 params
1587 .iter()
1588 .any(|p| p.name == "id" && p.location == "path"),
1589 "Should have 'id' path parameter"
1590 );
1591 }
1592
1593 #[test]
1595 fn test_openapi_spec_empty_without_routes() {
1596 let app = RustApi::new();
1597 let spec = app.openapi_spec();
1598
1599 assert!(
1601 spec.paths.is_empty(),
1602 "OpenAPI spec should have no paths without routes"
1603 );
1604 }
1605
1606 #[test]
1611 fn test_rustapi_nest_delegates_to_router_nest() {
1612 use crate::router::RouteMatch;
1613
1614 async fn list_users() -> &'static str {
1615 "list users"
1616 }
1617 async fn get_user() -> &'static str {
1618 "get user"
1619 }
1620 async fn create_user() -> &'static str {
1621 "create user"
1622 }
1623
1624 let users_router = Router::new()
1626 .route("/", get(list_users))
1627 .route("/create", post(create_user))
1628 .route("/{id}", get(get_user));
1629
1630 let app = RustApi::new().nest("/api/v1/users", users_router);
1632 let router = app.into_router();
1633
1634 let routes = router.registered_routes();
1636 assert_eq!(routes.len(), 3, "Should have 3 routes registered");
1637
1638 assert!(
1640 routes.contains_key("/api/v1/users"),
1641 "Should have /api/v1/users route"
1642 );
1643 assert!(
1644 routes.contains_key("/api/v1/users/create"),
1645 "Should have /api/v1/users/create route"
1646 );
1647 assert!(
1648 routes.contains_key("/api/v1/users/:id"),
1649 "Should have /api/v1/users/:id route"
1650 );
1651
1652 match router.match_route("/api/v1/users", &Method::GET) {
1654 RouteMatch::Found { params, .. } => {
1655 assert!(params.is_empty(), "Root route should have no params");
1656 }
1657 _ => panic!("GET /api/v1/users should be found"),
1658 }
1659
1660 match router.match_route("/api/v1/users/create", &Method::POST) {
1661 RouteMatch::Found { params, .. } => {
1662 assert!(params.is_empty(), "Create route should have no params");
1663 }
1664 _ => panic!("POST /api/v1/users/create should be found"),
1665 }
1666
1667 match router.match_route("/api/v1/users/123", &Method::GET) {
1668 RouteMatch::Found { params, .. } => {
1669 assert_eq!(
1670 params.get("id"),
1671 Some(&"123".to_string()),
1672 "Should extract id param"
1673 );
1674 }
1675 _ => panic!("GET /api/v1/users/123 should be found"),
1676 }
1677
1678 match router.match_route("/api/v1/users", &Method::DELETE) {
1680 RouteMatch::MethodNotAllowed { allowed } => {
1681 assert!(allowed.contains(&Method::GET), "Should allow GET");
1682 }
1683 _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
1684 }
1685 }
1686
1687 #[test]
1692 fn test_rustapi_nest_includes_routes_in_openapi_spec() {
1693 async fn list_items() -> &'static str {
1694 "list items"
1695 }
1696 async fn get_item() -> &'static str {
1697 "get item"
1698 }
1699
1700 let items_router = Router::new()
1702 .route("/", get(list_items))
1703 .route("/{item_id}", get(get_item));
1704
1705 let app = RustApi::new().nest("/api/items", items_router);
1707
1708 let spec = app.openapi_spec();
1710
1711 assert!(
1713 spec.paths.contains_key("/api/items"),
1714 "Should have /api/items in OpenAPI"
1715 );
1716 assert!(
1717 spec.paths.contains_key("/api/items/{item_id}"),
1718 "Should have /api/items/{{item_id}} in OpenAPI"
1719 );
1720
1721 let list_path = spec.paths.get("/api/items").unwrap();
1723 assert!(
1724 list_path.get.is_some(),
1725 "Should have GET operation for /api/items"
1726 );
1727
1728 let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
1729 assert!(
1730 get_path.get.is_some(),
1731 "Should have GET operation for /api/items/{{item_id}}"
1732 );
1733
1734 let get_op = get_path.get.as_ref().unwrap();
1736 assert!(get_op.parameters.is_some(), "Should have parameters");
1737 let params = get_op.parameters.as_ref().unwrap();
1738 assert!(
1739 params
1740 .iter()
1741 .any(|p| p.name == "item_id" && p.location == "path"),
1742 "Should have 'item_id' path parameter"
1743 );
1744 }
1745}
1746
1747#[cfg(feature = "swagger-ui")]
1749fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1750 req.headers()
1751 .get(http::header::AUTHORIZATION)
1752 .and_then(|v| v.to_str().ok())
1753 .map(|auth| auth == expected)
1754 .unwrap_or(false)
1755}
1756
1757#[cfg(feature = "swagger-ui")]
1759fn unauthorized_response() -> crate::Response {
1760 http::Response::builder()
1761 .status(http::StatusCode::UNAUTHORIZED)
1762 .header(
1763 http::header::WWW_AUTHENTICATE,
1764 "Basic realm=\"API Documentation\"",
1765 )
1766 .header(http::header::CONTENT_TYPE, "text/plain")
1767 .body(http_body_util::Full::new(bytes::Bytes::from(
1768 "Unauthorized",
1769 )))
1770 .unwrap()
1771}
1772
1773pub struct RustApiConfig {
1775 docs_path: Option<String>,
1776 docs_enabled: bool,
1777 api_title: String,
1778 api_version: String,
1779 api_description: Option<String>,
1780 body_limit: Option<usize>,
1781 layers: LayerStack,
1782}
1783
1784impl Default for RustApiConfig {
1785 fn default() -> Self {
1786 Self::new()
1787 }
1788}
1789
1790impl RustApiConfig {
1791 pub fn new() -> Self {
1792 Self {
1793 docs_path: Some("/docs".to_string()),
1794 docs_enabled: true,
1795 api_title: "RustAPI".to_string(),
1796 api_version: "1.0.0".to_string(),
1797 api_description: None,
1798 body_limit: None,
1799 layers: LayerStack::new(),
1800 }
1801 }
1802
1803 pub fn docs_path(mut self, path: impl Into<String>) -> Self {
1805 self.docs_path = Some(path.into());
1806 self
1807 }
1808
1809 pub fn docs_enabled(mut self, enabled: bool) -> Self {
1811 self.docs_enabled = enabled;
1812 self
1813 }
1814
1815 pub fn openapi_info(
1817 mut self,
1818 title: impl Into<String>,
1819 version: impl Into<String>,
1820 description: Option<impl Into<String>>,
1821 ) -> Self {
1822 self.api_title = title.into();
1823 self.api_version = version.into();
1824 self.api_description = description.map(|d| d.into());
1825 self
1826 }
1827
1828 pub fn body_limit(mut self, limit: usize) -> Self {
1830 self.body_limit = Some(limit);
1831 self
1832 }
1833
1834 pub fn layer<L>(mut self, layer: L) -> Self
1836 where
1837 L: MiddlewareLayer,
1838 {
1839 self.layers.push(Box::new(layer));
1840 self
1841 }
1842
1843 pub fn build(self) -> RustApi {
1845 let mut app = RustApi::new().mount_auto_routes_grouped();
1846
1847 if let Some(limit) = self.body_limit {
1849 app = app.body_limit(limit);
1850 }
1851
1852 app = app.openapi_info(
1853 &self.api_title,
1854 &self.api_version,
1855 self.api_description.as_deref(),
1856 );
1857
1858 #[cfg(feature = "swagger-ui")]
1859 if self.docs_enabled {
1860 if let Some(path) = self.docs_path {
1861 app = app.docs(&path);
1862 }
1863 }
1864
1865 app.layers.extend(self.layers);
1868
1869 app
1870 }
1871
1872 pub async fn run(
1874 self,
1875 addr: impl AsRef<str>,
1876 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1877 self.build().run(addr.as_ref()).await
1878 }
1879}