1use crate::doc::{
6 DocMeta, RequestMeta, ResponseMeta, lookup_doc_by_handler_ptr, lookup_request_by_handler_ptr,
7 lookup_response_by_handler_ptr,
8};
9use crate::{OpenApiDoc, schema::PathInfo};
10use silent::prelude::Route;
11use utoipa::openapi::{PathItem, ResponseBuilder, path::Operation};
12
13#[derive(Debug, Clone)]
15pub struct DocumentedRoute {
16 pub route: Route,
18 pub path_docs: Vec<PathInfo>,
20}
21
22impl DocumentedRoute {
23 pub fn new(route: Route) -> Self {
25 Self {
26 route,
27 path_docs: Vec::new(),
28 }
29 }
30
31 pub fn add_path_doc(mut self, path_info: PathInfo) -> Self {
33 self.path_docs.push(path_info);
34 self
35 }
36
37 pub fn add_path_docs(mut self, path_docs: Vec<PathInfo>) -> Self {
39 self.path_docs.extend(path_docs);
40 self
41 }
42
43 pub fn into_route(self) -> Route {
45 self.route
46 }
47
48 pub fn generate_path_items(&self, base_path: &str) -> Vec<(String, PathItem)> {
50 let mut path_items = Vec::new();
51
52 for path_doc in &self.path_docs {
53 let full_path = if base_path.is_empty() {
54 path_doc.path.clone()
55 } else {
56 format!("{}{}", base_path.trim_end_matches('/'), &path_doc.path)
57 };
58
59 let openapi_path = convert_path_format(&full_path);
61
62 let operation = create_operation_from_path_info(path_doc);
64
65 let path_item = create_or_update_path_item(None, &path_doc.method, operation);
67
68 path_items.push((openapi_path, path_item));
69 }
70
71 path_items
72 }
73}
74
75pub trait RouteDocumentation {
79 fn collect_openapi_paths(&self, base_path: &str) -> Vec<(String, PathItem)>;
89
90 fn generate_openapi_doc(
102 &self,
103 title: &str,
104 version: &str,
105 description: Option<&str>,
106 ) -> OpenApiDoc {
107 let mut doc = OpenApiDoc::new(title, version);
108
109 if let Some(desc) = description {
110 doc = doc.description(desc);
111 }
112
113 let paths = self.collect_openapi_paths("");
114 doc = doc.add_paths(paths).apply_registered_schemas();
115
116 doc
117 }
118}
119
120impl RouteDocumentation for Route {
121 fn collect_openapi_paths(&self, base_path: &str) -> Vec<(String, PathItem)> {
122 let mut paths = Vec::new();
123 collect_paths_recursive(self, base_path, &mut paths);
124 paths
125 }
126}
127
128fn collect_paths_recursive(route: &Route, current_path: &str, paths: &mut Vec<(String, PathItem)>) {
130 let full_path = if current_path.is_empty() {
131 route.path.clone()
132 } else if route.path.is_empty() {
133 current_path.to_string()
134 } else {
135 format!(
136 "{}/{}",
137 current_path.trim_end_matches('/'),
138 route.path.trim_start_matches('/')
139 )
140 };
141
142 for (method, handler) in &route.handler {
144 let openapi_path = convert_path_format(&full_path);
145 let ptr = std::sync::Arc::as_ptr(handler) as *const () as usize;
146 let doc = lookup_doc_by_handler_ptr(ptr);
147 let resp = lookup_response_by_handler_ptr(ptr);
148 let req_meta = lookup_request_by_handler_ptr(ptr);
149 let operation = create_operation_with_doc(method, &full_path, doc, resp, req_meta);
150 let path_item = create_or_update_path_item(None, method, operation);
151
152 if let Some((_, existing_item)) = paths.iter_mut().find(|(path, _)| path == &openapi_path) {
154 *existing_item = merge_path_items(existing_item, &path_item);
156 } else {
157 paths.push((openapi_path, path_item));
158 }
159 }
160
161 for child in &route.children {
163 collect_paths_recursive(child, &full_path, paths);
164 }
165}
166
167fn convert_path_format(silent_path: &str) -> String {
172 let mut result = if silent_path.is_empty() {
174 "/".to_string()
175 } else if silent_path.starts_with('/') {
176 silent_path.to_string()
177 } else {
178 format!("/{}", silent_path)
179 };
180
181 while let Some(start) = result.find('<') {
183 if let Some(end) = result[start..].find('>') {
184 let full_match = &result[start..start + end + 1];
185 if let Some(colon_pos) = full_match.find(':') {
186 let param_name = &full_match[1..colon_pos];
187 let replacement = format!("{{{}}}", param_name);
188 result = result.replace(full_match, &replacement);
189 } else {
190 break;
191 }
192 } else {
193 break;
194 }
195 }
196
197 result
198}
199
200fn create_operation_from_path_info(path_info: &PathInfo) -> Operation {
202 use utoipa::openapi::path::OperationBuilder;
203
204 let mut builder = OperationBuilder::new();
205
206 if let Some(ref operation_id) = path_info.operation_id {
207 builder = builder.operation_id(Some(operation_id.clone()));
208 }
209
210 if let Some(ref summary) = path_info.summary {
211 builder = builder.summary(Some(summary.clone()));
212 }
213
214 if let Some(ref description) = path_info.description {
215 builder = builder.description(Some(description.clone()));
216 }
217
218 if !path_info.tags.is_empty() {
219 builder = builder.tags(Some(path_info.tags.clone()));
220 }
221
222 let default_response = ResponseBuilder::new()
224 .description("Successful response")
225 .build();
226
227 builder = builder.response("200", default_response);
228
229 builder.build()
230}
231
232fn create_operation_with_doc(
234 method: &http::Method,
235 path: &str,
236 doc: Option<DocMeta>,
237 resp: Option<ResponseMeta>,
238 req_meta: Option<Vec<RequestMeta>>,
239) -> Operation {
240 use utoipa::openapi::Required;
241 use utoipa::openapi::path::{OperationBuilder, ParameterBuilder};
242
243 let default_summary = format!("{} {}", method, path);
244 let default_description = format!("Handler for {} {}", method, path);
245 let (summary, description) = match doc {
246 Some(DocMeta {
247 summary,
248 description,
249 }) => (
250 summary.unwrap_or(default_summary),
251 description.unwrap_or(default_description),
252 ),
253 None => (default_summary, default_description),
254 };
255
256 let sanitized_path: String = path
258 .chars()
259 .map(|c| match c {
260 'a'..='z' | 'A'..='Z' | '0'..='9' => c,
261 _ => '_',
262 })
263 .collect();
264 let operation_id = format!("{}_{}", method.as_str().to_lowercase(), sanitized_path)
265 .trim_matches('_')
266 .to_string();
267
268 let default_tag = path
270 .split('/')
271 .find(|s| !s.is_empty())
272 .map(|s| s.to_string());
273
274 let mut response_builder = ResponseBuilder::new().description("Successful response");
275 if let Some(rm) = resp {
276 match rm {
277 ResponseMeta::TextPlain => {
278 use utoipa::openapi::{
279 RefOr,
280 content::ContentBuilder,
281 schema::{ObjectBuilder, Schema},
282 };
283 let content = ContentBuilder::new()
284 .schema::<RefOr<Schema>>(Some(RefOr::T(Schema::Object(
285 ObjectBuilder::new().build(),
286 ))))
287 .build();
288 response_builder = response_builder.content("text/plain", content);
289 }
290 ResponseMeta::Json { type_name } => {
291 use utoipa::openapi::{Ref, RefOr, content::ContentBuilder, schema::Schema};
292 let schema_ref = RefOr::Ref(Ref::from_schema_name(type_name));
293 let content = ContentBuilder::new()
294 .schema::<RefOr<Schema>>(Some(schema_ref))
295 .build();
296 response_builder = response_builder.content("application/json", content);
297 }
298 }
299 }
300 let default_response = response_builder.build();
301
302 let mut builder = OperationBuilder::new()
304 .summary(Some(summary))
305 .description(Some(description))
306 .operation_id(Some(operation_id))
307 .response("200", default_response);
308
309 if let Some(tag) = default_tag {
310 builder = builder.tags(Some(vec![tag]));
311 }
312
313 if let Some(req_metas) = req_meta {
315 for meta in req_metas {
316 match meta {
317 RequestMeta::JsonBody { type_name } => {
318 use utoipa::openapi::{
319 Ref, RefOr, content::ContentBuilder, request_body::RequestBodyBuilder,
320 schema::Schema,
321 };
322 let schema_ref = RefOr::Ref(Ref::from_schema_name(type_name));
323 let content = ContentBuilder::new()
324 .schema::<RefOr<Schema>>(Some(schema_ref))
325 .build();
326 let request_body = RequestBodyBuilder::new()
327 .content("application/json", content)
328 .required(Some(Required::True))
329 .build();
330 builder = builder.request_body(Some(request_body));
331 }
332 RequestMeta::FormBody { type_name } => {
333 use utoipa::openapi::{
334 Ref, RefOr, content::ContentBuilder, request_body::RequestBodyBuilder,
335 schema::Schema,
336 };
337 let schema_ref = RefOr::Ref(Ref::from_schema_name(type_name));
338 let content = ContentBuilder::new()
339 .schema::<RefOr<Schema>>(Some(schema_ref))
340 .build();
341 let request_body = RequestBodyBuilder::new()
342 .content("application/x-www-form-urlencoded", content)
343 .required(Some(Required::True))
344 .build();
345 builder = builder.request_body(Some(request_body));
346 }
347 RequestMeta::QueryParams { type_name } => {
348 let param = ParameterBuilder::new()
350 .name(type_name)
351 .parameter_in(utoipa::openapi::path::ParameterIn::Query)
352 .required(Required::False)
353 .schema::<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>(Some(
354 utoipa::openapi::RefOr::Ref(utoipa::openapi::Ref::from_schema_name(
355 type_name,
356 )),
357 ))
358 .build();
359 builder = builder.parameter(param);
360 }
361 }
362 }
363 }
364
365 {
367 let mut i = 0usize;
368 let mut found_any = false;
369 while let Some(start) = path[i..].find('<') {
370 let abs_start = i + start;
371 if let Some(end_rel) = path[abs_start..].find('>') {
372 let abs_end = abs_start + end_rel;
373 let inner = &path[abs_start + 1..abs_end];
374 let mut it = inner.splitn(2, ':');
375 let name = it.next().unwrap_or("");
376
377 if !name.is_empty() {
378 let param = ParameterBuilder::new()
379 .name(name)
380 .parameter_in(utoipa::openapi::path::ParameterIn::Path)
381 .required(Required::True)
382 .schema::<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>(None)
383 .build();
384 builder = builder.parameter(param);
385 found_any = true;
386 }
387 i = abs_end + 1;
388 } else {
389 break;
390 }
391 }
392
393 if !found_any {
395 let mut idx = 0usize;
396 while let Some(start) = path[idx..].find('{') {
397 let abs_start = idx + start;
398 if let Some(end_rel) = path[abs_start..].find('}') {
399 let abs_end = abs_start + end_rel;
400 let name = &path[abs_start + 1..abs_end];
401 let param = ParameterBuilder::new()
402 .name(name)
403 .parameter_in(utoipa::openapi::path::ParameterIn::Path)
404 .required(Required::True)
405 .schema::<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>(None)
406 .build();
407 builder = builder.parameter(param);
408 idx = abs_end + 1;
409 } else {
410 break;
411 }
412 }
413 }
414 }
415
416 builder.build()
417}
418
419fn create_or_update_path_item(
421 _existing: Option<&PathItem>,
422 method: &http::Method,
423 operation: Operation,
424) -> PathItem {
425 let mut item = PathItem::default();
426 match *method {
427 http::Method::GET => item.get = Some(operation),
428 http::Method::POST => item.post = Some(operation),
429 http::Method::PUT => item.put = Some(operation),
430 http::Method::DELETE => item.delete = Some(operation),
431 http::Method::PATCH => item.patch = Some(operation),
432 http::Method::HEAD => item.head = Some(operation),
433 http::Method::OPTIONS => item.options = Some(operation),
434 http::Method::TRACE => item.trace = Some(operation),
435 _ => {}
436 }
437 item
438}
439
440fn merge_path_items(item1: &PathItem, item2: &PathItem) -> PathItem {
442 let mut out = PathItem::default();
443 out.get = item1.get.clone().or(item2.get.clone());
444 out.post = item1.post.clone().or(item2.post.clone());
445 out.put = item1.put.clone().or(item2.put.clone());
446 out.delete = item1.delete.clone().or(item2.delete.clone());
447 out.patch = item1.patch.clone().or(item2.patch.clone());
448 out.head = item1.head.clone().or(item2.head.clone());
449 out.options = item1.options.clone().or(item2.options.clone());
450 out.trace = item1.trace.clone().or(item2.trace.clone());
451 out
452}
453
454pub trait RouteOpenApiExt {
456 fn to_openapi(&self, title: &str, version: &str) -> utoipa::openapi::OpenApi;
457}
458
459impl RouteOpenApiExt for Route {
460 fn to_openapi(&self, title: &str, version: &str) -> utoipa::openapi::OpenApi {
461 self.generate_openapi_doc(title, version, None)
462 .into_openapi()
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::doc::{DocMeta, RequestMeta, ResponseMeta};
470
471 #[test]
472 fn test_path_format_conversion() {
473 assert_eq!(
474 convert_path_format("/users/<id:i64>/posts"),
475 "/users/{id}/posts"
476 );
477
478 assert_eq!(
479 convert_path_format("/api/v1/users/<user_id:String>/items/<item_id:u32>"),
480 "/api/v1/users/{user_id}/items/{item_id}"
481 );
482
483 assert_eq!(convert_path_format("/simple/path"), "/simple/path");
484 assert_eq!(convert_path_format("svc"), "/svc");
485 assert_eq!(convert_path_format(""), "/");
486 }
487
488 #[test]
489 fn test_documented_route_creation() {
490 let route = Route::new("users");
491 let doc_route = DocumentedRoute::new(route);
492
493 assert_eq!(doc_route.path_docs.len(), 0);
494 }
495
496 #[test]
497 fn test_path_info_to_operation() {
498 let path_info = PathInfo::new(http::Method::GET, "/users/{id}")
499 .operation_id("get_user")
500 .summary("获取用户")
501 .description("根据ID获取用户信息")
502 .tag("users");
503
504 let operation = create_operation_from_path_info(&path_info);
505
506 assert_eq!(operation.operation_id, Some("get_user".to_string()));
507 assert_eq!(operation.summary, Some("获取用户".to_string()));
508 assert_eq!(
509 operation.description,
510 Some("根据ID获取用户信息".to_string())
511 );
512 assert_eq!(operation.tags, Some(vec!["users".to_string()]));
513 }
514
515 #[test]
516 fn test_documented_route_generate_items() {
517 let route = DocumentedRoute::new(Route::new(""))
518 .add_path_doc(PathInfo::new(http::Method::GET, "/ping").summary("ping"));
519 let items = route.generate_path_items("");
520 let (_p, item) = items.into_iter().find(|(p, _)| p == "/ping").unwrap();
521 assert!(item.get.is_some());
522 }
523
524 #[test]
525 fn test_generate_openapi_doc_with_registered_schema() {
526 use serde::Serialize;
527 use utoipa::ToSchema;
528 #[derive(Serialize, ToSchema)]
529 struct MyType {
530 id: i32,
531 }
532 crate::doc::register_schema_for::<MyType>();
533 let route = Route::new("");
534 let openapi = route.generate_openapi_doc("t", "1", None).into_openapi();
535 assert!(
536 openapi
537 .components
538 .as_ref()
539 .expect("components")
540 .schemas
541 .contains_key("MyType")
542 );
543 }
544
545 #[test]
546 fn test_collect_paths_with_multiple_methods() {
547 async fn h1(_r: silent::Request) -> silent::Result<silent::Response> {
548 Ok(silent::Response::text("ok"))
549 }
550 async fn h2(_r: silent::Request) -> silent::Result<silent::Response> {
551 Ok(silent::Response::text("ok"))
552 }
553 let route = Route::new("svc").get(h1).post(h2);
554 let paths = route.collect_openapi_paths("");
555 let (_p, item) = paths.into_iter().find(|(p, _)| p == "/svc").expect("/svc");
557 assert!(item.get.is_some());
558 assert!(item.post.is_some());
559 }
560
561 #[test]
562 fn test_operation_with_text_plain() {
563 let op = create_operation_with_doc(
564 &http::Method::GET,
565 "/hello",
566 Some(DocMeta {
567 summary: Some("s".into()),
568 description: Some("d".into()),
569 }),
570 Some(ResponseMeta::TextPlain),
571 None,
572 );
573 let resp = op.responses.responses.get("200").expect("200 resp");
574 let resp = match resp {
575 utoipa::openapi::RefOr::T(r) => r,
576 _ => panic!("expected T"),
577 };
578 let content = &resp.content;
579 assert!(content.contains_key("text/plain"));
580 }
581
582 #[test]
583 fn test_operation_with_json_ref() {
584 let op = create_operation_with_doc(
585 &http::Method::GET,
586 "/users/{id}",
587 None,
588 Some(ResponseMeta::Json { type_name: "User" }),
589 None,
590 );
591 let resp = op.responses.responses.get("200").expect("200 resp");
592 let resp = match resp {
593 utoipa::openapi::RefOr::T(r) => r,
594 _ => panic!("expected T"),
595 };
596 let content = &resp.content;
597 let mt = content.get("application/json").expect("app/json");
598 let schema = mt.schema.as_ref().expect("schema");
599 match schema {
600 utoipa::openapi::RefOr::Ref(r) => assert!(r.ref_location.ends_with("/User")),
601 _ => panic!("ref expected"),
602 }
603 }
604
605 #[test]
606 fn test_operation_with_json_request_body() {
607 let op = create_operation_with_doc(
608 &http::Method::POST,
609 "/users",
610 None,
611 None,
612 Some(vec![RequestMeta::JsonBody {
613 type_name: "CreateUser",
614 }]),
615 );
616 let body = op.request_body.as_ref().expect("request body");
617 let content = body.content.get("application/json").expect("app/json");
618 let schema = content.schema.as_ref().expect("schema");
619 match schema {
620 utoipa::openapi::RefOr::Ref(r) => {
621 assert!(r.ref_location.ends_with("/CreateUser"))
622 }
623 _ => panic!("ref expected"),
624 }
625 }
626
627 #[test]
628 fn test_operation_with_form_request_body() {
629 let op = create_operation_with_doc(
630 &http::Method::POST,
631 "/login",
632 None,
633 None,
634 Some(vec![RequestMeta::FormBody {
635 type_name: "LoginForm",
636 }]),
637 );
638 let body = op.request_body.as_ref().expect("request body");
639 assert!(
640 body.content
641 .contains_key("application/x-www-form-urlencoded")
642 );
643 }
644
645 #[test]
646 fn test_operation_with_query_params() {
647 let op = create_operation_with_doc(
648 &http::Method::GET,
649 "/search",
650 None,
651 None,
652 Some(vec![RequestMeta::QueryParams {
653 type_name: "SearchQuery",
654 }]),
655 );
656 let params = op.parameters.as_ref().expect("should have parameters");
657 assert!(!params.is_empty());
658 }
659
660 #[test]
661 fn test_merge_path_items_get_post() {
662 let get = create_or_update_path_item(
663 None,
664 &http::Method::GET,
665 create_operation_with_doc(&http::Method::GET, "/a", None, None, None),
666 );
667 let post = create_or_update_path_item(
668 None,
669 &http::Method::POST,
670 create_operation_with_doc(&http::Method::POST, "/a", None, None, None),
671 );
672 let merged = merge_path_items(&get, &post);
673 assert!(merged.get.is_some());
674 assert!(merged.post.is_some());
675 }
676
677 #[test]
678 fn test_merge_prefers_first_for_same_method() {
679 let op1 = create_operation_with_doc(&http::Method::GET, "/a", None, None, None);
680 let mut item1 = PathItem::default();
681 item1.get = Some(op1);
682 let op2 = create_operation_with_doc(&http::Method::GET, "/a", None, None, None);
683 let mut item2 = PathItem::default();
684 item2.get = Some(op2);
685 let merged = merge_path_items(&item1, &item2);
686 assert!(merged.get.is_some());
687 }
688}