1use crate::doc::{
6 DocMeta, ResponseMeta, lookup_doc_by_handler_ptr, lookup_response_by_handler_ptr,
7};
8use crate::{OpenApiDoc, schema::PathInfo};
9use silent::prelude::Route;
10use utoipa::openapi::{PathItem, ResponseBuilder, path::Operation};
11
12#[derive(Debug, Clone)]
14pub struct DocumentedRoute {
15 pub route: Route,
17 pub path_docs: Vec<PathInfo>,
19}
20
21impl DocumentedRoute {
22 pub fn new(route: Route) -> Self {
24 Self {
25 route,
26 path_docs: Vec::new(),
27 }
28 }
29
30 pub fn add_path_doc(mut self, path_info: PathInfo) -> Self {
32 self.path_docs.push(path_info);
33 self
34 }
35
36 pub fn add_path_docs(mut self, path_docs: Vec<PathInfo>) -> Self {
38 self.path_docs.extend(path_docs);
39 self
40 }
41
42 pub fn into_route(self) -> Route {
44 self.route
45 }
46
47 pub fn generate_path_items(&self, base_path: &str) -> Vec<(String, PathItem)> {
49 let mut path_items = Vec::new();
50
51 for path_doc in &self.path_docs {
52 let full_path = if base_path.is_empty() {
53 path_doc.path.clone()
54 } else {
55 format!("{}{}", base_path.trim_end_matches('/'), &path_doc.path)
56 };
57
58 let openapi_path = convert_path_format(&full_path);
60
61 let operation = create_operation_from_path_info(path_doc);
63
64 let path_item = create_or_update_path_item(None, &path_doc.method, operation);
66
67 path_items.push((openapi_path, path_item));
68 }
69
70 path_items
71 }
72}
73
74pub trait RouteDocumentation {
78 fn collect_openapi_paths(&self, base_path: &str) -> Vec<(String, PathItem)>;
88
89 fn generate_openapi_doc(
101 &self,
102 title: &str,
103 version: &str,
104 description: Option<&str>,
105 ) -> OpenApiDoc {
106 let mut doc = OpenApiDoc::new(title, version);
107
108 if let Some(desc) = description {
109 doc = doc.description(desc);
110 }
111
112 let paths = self.collect_openapi_paths("");
113 doc = doc.add_paths(paths).apply_registered_schemas();
114
115 doc
116 }
117}
118
119impl RouteDocumentation for Route {
120 fn collect_openapi_paths(&self, base_path: &str) -> Vec<(String, PathItem)> {
121 let mut paths = Vec::new();
122 collect_paths_recursive(self, base_path, &mut paths);
123 paths
124 }
125}
126
127fn collect_paths_recursive(route: &Route, current_path: &str, paths: &mut Vec<(String, PathItem)>) {
129 let full_path = if current_path.is_empty() {
130 route.path.clone()
131 } else if route.path.is_empty() {
132 current_path.to_string()
133 } else {
134 format!(
135 "{}/{}",
136 current_path.trim_end_matches('/'),
137 route.path.trim_start_matches('/')
138 )
139 };
140
141 for (method, handler) in &route.handler {
143 let openapi_path = convert_path_format(&full_path);
144 let ptr = std::sync::Arc::as_ptr(handler) as *const () as usize;
145 let doc = lookup_doc_by_handler_ptr(ptr);
146 let resp = lookup_response_by_handler_ptr(ptr);
147 let operation = create_operation_with_doc(method, &full_path, doc, resp);
148 let path_item = create_or_update_path_item(None, method, operation);
149
150 if let Some((_, existing_item)) = paths.iter_mut().find(|(path, _)| path == &openapi_path) {
152 *existing_item = merge_path_items(existing_item, &path_item);
154 } else {
155 paths.push((openapi_path, path_item));
156 }
157 }
158
159 for child in &route.children {
161 collect_paths_recursive(child, &full_path, paths);
162 }
163}
164
165fn convert_path_format(silent_path: &str) -> String {
170 let mut result = if silent_path.is_empty() {
172 "/".to_string()
173 } else if silent_path.starts_with('/') {
174 silent_path.to_string()
175 } else {
176 format!("/{}", silent_path)
177 };
178
179 while let Some(start) = result.find('<') {
181 if let Some(end) = result[start..].find('>') {
182 let full_match = &result[start..start + end + 1];
183 if let Some(colon_pos) = full_match.find(':') {
184 let param_name = &full_match[1..colon_pos];
185 let replacement = format!("{{{}}}", param_name);
186 result = result.replace(full_match, &replacement);
187 } else {
188 break;
189 }
190 } else {
191 break;
192 }
193 }
194
195 result
196}
197
198fn create_operation_from_path_info(path_info: &PathInfo) -> Operation {
200 use utoipa::openapi::path::OperationBuilder;
201
202 let mut builder = OperationBuilder::new();
203
204 if let Some(ref operation_id) = path_info.operation_id {
205 builder = builder.operation_id(Some(operation_id.clone()));
206 }
207
208 if let Some(ref summary) = path_info.summary {
209 builder = builder.summary(Some(summary.clone()));
210 }
211
212 if let Some(ref description) = path_info.description {
213 builder = builder.description(Some(description.clone()));
214 }
215
216 if !path_info.tags.is_empty() {
217 builder = builder.tags(Some(path_info.tags.clone()));
218 }
219
220 let default_response = ResponseBuilder::new()
222 .description("Successful response")
223 .build();
224
225 builder = builder.response("200", default_response);
226
227 builder.build()
228}
229
230fn create_operation_with_doc(
232 method: &http::Method,
233 path: &str,
234 doc: Option<DocMeta>,
235 resp: Option<ResponseMeta>,
236) -> Operation {
237 use utoipa::openapi::Required;
238 use utoipa::openapi::path::{OperationBuilder, ParameterBuilder};
239
240 let default_summary = format!("{} {}", method, path);
241 let default_description = format!("Handler for {} {}", method, path);
242 let (summary, description) = match doc {
243 Some(DocMeta {
244 summary,
245 description,
246 }) => (
247 summary.unwrap_or(default_summary),
248 description.unwrap_or(default_description),
249 ),
250 None => (default_summary, default_description),
251 };
252
253 let sanitized_path: String = path
255 .chars()
256 .map(|c| match c {
257 'a'..='z' | 'A'..='Z' | '0'..='9' => c,
258 _ => '_',
259 })
260 .collect();
261 let operation_id = format!("{}_{}", method.as_str().to_lowercase(), sanitized_path)
262 .trim_matches('_')
263 .to_string();
264
265 let default_tag = path
267 .split('/')
268 .find(|s| !s.is_empty())
269 .map(|s| s.to_string());
270
271 let mut response_builder = ResponseBuilder::new().description("Successful response");
272 if let Some(rm) = resp {
273 match rm {
274 ResponseMeta::TextPlain => {
275 use utoipa::openapi::{
276 RefOr,
277 content::ContentBuilder,
278 schema::{ObjectBuilder, Schema},
279 };
280 let content = ContentBuilder::new()
281 .schema::<RefOr<Schema>>(Some(RefOr::T(Schema::Object(
282 ObjectBuilder::new().build(),
283 ))))
284 .build();
285 response_builder = response_builder.content("text/plain", content);
286 }
287 ResponseMeta::Json { type_name } => {
288 use utoipa::openapi::{Ref, RefOr, content::ContentBuilder, schema::Schema};
289 let schema_ref = RefOr::Ref(Ref::from_schema_name(type_name));
290 let content = ContentBuilder::new()
291 .schema::<RefOr<Schema>>(Some(schema_ref))
292 .build();
293 response_builder = response_builder.content("application/json", content);
294 }
295 }
296 }
297 let default_response = response_builder.build();
298
299 let mut builder = OperationBuilder::new()
301 .summary(Some(summary))
302 .description(Some(description))
303 .operation_id(Some(operation_id))
304 .response("200", default_response);
305
306 if let Some(tag) = default_tag {
307 builder = builder.tags(Some(vec![tag]));
308 }
309
310 {
312 let mut i = 0usize;
313 let mut found_any = false;
314 while let Some(start) = path[i..].find('<') {
315 let abs_start = i + start;
316 if let Some(end_rel) = path[abs_start..].find('>') {
317 let abs_end = abs_start + end_rel;
318 let inner = &path[abs_start + 1..abs_end];
319 let mut it = inner.splitn(2, ':');
320 let name = it.next().unwrap_or("");
321
322 if !name.is_empty() {
323 let param = ParameterBuilder::new()
324 .name(name)
325 .parameter_in(utoipa::openapi::path::ParameterIn::Path)
326 .required(Required::True)
327 .schema::<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>(None)
328 .build();
329 builder = builder.parameter(param);
330 found_any = true;
331 }
332 i = abs_end + 1;
333 } else {
334 break;
335 }
336 }
337
338 if !found_any {
340 let mut idx = 0usize;
341 while let Some(start) = path[idx..].find('{') {
342 let abs_start = idx + start;
343 if let Some(end_rel) = path[abs_start..].find('}') {
344 let abs_end = abs_start + end_rel;
345 let name = &path[abs_start + 1..abs_end];
346 let param = ParameterBuilder::new()
347 .name(name)
348 .parameter_in(utoipa::openapi::path::ParameterIn::Path)
349 .required(Required::True)
350 .schema::<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>(None)
351 .build();
352 builder = builder.parameter(param);
353 idx = abs_end + 1;
354 } else {
355 break;
356 }
357 }
358 }
359 }
360
361 builder.build()
362}
363
364fn create_or_update_path_item(
366 _existing: Option<&PathItem>,
367 method: &http::Method,
368 operation: Operation,
369) -> PathItem {
370 let mut item = PathItem::default();
371 match *method {
372 http::Method::GET => item.get = Some(operation),
373 http::Method::POST => item.post = Some(operation),
374 http::Method::PUT => item.put = Some(operation),
375 http::Method::DELETE => item.delete = Some(operation),
376 http::Method::PATCH => item.patch = Some(operation),
377 http::Method::HEAD => item.head = Some(operation),
378 http::Method::OPTIONS => item.options = Some(operation),
379 http::Method::TRACE => item.trace = Some(operation),
380 _ => {}
381 }
382 item
383}
384
385fn merge_path_items(item1: &PathItem, item2: &PathItem) -> PathItem {
387 let mut out = PathItem::default();
388 out.get = item1.get.clone().or(item2.get.clone());
389 out.post = item1.post.clone().or(item2.post.clone());
390 out.put = item1.put.clone().or(item2.put.clone());
391 out.delete = item1.delete.clone().or(item2.delete.clone());
392 out.patch = item1.patch.clone().or(item2.patch.clone());
393 out.head = item1.head.clone().or(item2.head.clone());
394 out.options = item1.options.clone().or(item2.options.clone());
395 out.trace = item1.trace.clone().or(item2.trace.clone());
396 out
397}
398
399pub trait RouteOpenApiExt {
401 fn to_openapi(&self, title: &str, version: &str) -> utoipa::openapi::OpenApi;
402}
403
404impl RouteOpenApiExt for Route {
405 fn to_openapi(&self, title: &str, version: &str) -> utoipa::openapi::OpenApi {
406 self.generate_openapi_doc(title, version, None)
407 .into_openapi()
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::doc::{DocMeta, ResponseMeta};
415
416 #[test]
417 fn test_path_format_conversion() {
418 assert_eq!(
419 convert_path_format("/users/<id:i64>/posts"),
420 "/users/{id}/posts"
421 );
422
423 assert_eq!(
424 convert_path_format("/api/v1/users/<user_id:String>/items/<item_id:u32>"),
425 "/api/v1/users/{user_id}/items/{item_id}"
426 );
427
428 assert_eq!(convert_path_format("/simple/path"), "/simple/path");
429 assert_eq!(convert_path_format("svc"), "/svc");
430 assert_eq!(convert_path_format(""), "/");
431 }
432
433 #[test]
434 fn test_documented_route_creation() {
435 let route = Route::new("users");
436 let doc_route = DocumentedRoute::new(route);
437
438 assert_eq!(doc_route.path_docs.len(), 0);
439 }
440
441 #[test]
442 fn test_path_info_to_operation() {
443 let path_info = PathInfo::new(http::Method::GET, "/users/{id}")
444 .operation_id("get_user")
445 .summary("获取用户")
446 .description("根据ID获取用户信息")
447 .tag("users");
448
449 let operation = create_operation_from_path_info(&path_info);
450
451 assert_eq!(operation.operation_id, Some("get_user".to_string()));
452 assert_eq!(operation.summary, Some("获取用户".to_string()));
453 assert_eq!(
454 operation.description,
455 Some("根据ID获取用户信息".to_string())
456 );
457 assert_eq!(operation.tags, Some(vec!["users".to_string()]));
458 }
459
460 #[test]
461 fn test_documented_route_generate_items() {
462 let route = DocumentedRoute::new(Route::new(""))
463 .add_path_doc(PathInfo::new(http::Method::GET, "/ping").summary("ping"));
464 let items = route.generate_path_items("");
465 let (_p, item) = items.into_iter().find(|(p, _)| p == "/ping").unwrap();
466 assert!(item.get.is_some());
467 }
468
469 #[test]
470 fn test_generate_openapi_doc_with_registered_schema() {
471 use serde::Serialize;
472 use utoipa::ToSchema;
473 #[derive(Serialize, ToSchema)]
474 struct MyType {
475 id: i32,
476 }
477 crate::doc::register_schema_for::<MyType>();
478 let route = Route::new("");
479 let openapi = route.generate_openapi_doc("t", "1", None).into_openapi();
480 assert!(
481 openapi
482 .components
483 .as_ref()
484 .expect("components")
485 .schemas
486 .contains_key("MyType")
487 );
488 }
489
490 #[test]
491 fn test_collect_paths_with_multiple_methods() {
492 async fn h1(_r: silent::Request) -> silent::Result<silent::Response> {
493 Ok(silent::Response::text("ok"))
494 }
495 async fn h2(_r: silent::Request) -> silent::Result<silent::Response> {
496 Ok(silent::Response::text("ok"))
497 }
498 let route = Route::new("svc").get(h1).post(h2);
499 let paths = route.collect_openapi_paths("");
500 let (_p, item) = paths.into_iter().find(|(p, _)| p == "/svc").expect("/svc");
502 assert!(item.get.is_some());
503 assert!(item.post.is_some());
504 }
505
506 #[test]
507 fn test_operation_with_text_plain() {
508 let op = create_operation_with_doc(
509 &http::Method::GET,
510 "/hello",
511 Some(DocMeta {
512 summary: Some("s".into()),
513 description: Some("d".into()),
514 }),
515 Some(ResponseMeta::TextPlain),
516 );
517 let resp = op.responses.responses.get("200").expect("200 resp");
518 let resp = match resp {
519 utoipa::openapi::RefOr::T(r) => r,
520 _ => panic!("expected T"),
521 };
522 let content = &resp.content;
523 assert!(content.contains_key("text/plain"));
524 }
525
526 #[test]
527 fn test_operation_with_json_ref() {
528 let op = create_operation_with_doc(
529 &http::Method::GET,
530 "/users/{id}",
531 None,
532 Some(ResponseMeta::Json { type_name: "User" }),
533 );
534 let resp = op.responses.responses.get("200").expect("200 resp");
535 let resp = match resp {
536 utoipa::openapi::RefOr::T(r) => r,
537 _ => panic!("expected T"),
538 };
539 let content = &resp.content;
540 let mt = content.get("application/json").expect("app/json");
541 let schema = mt.schema.as_ref().expect("schema");
542 match schema {
543 utoipa::openapi::RefOr::Ref(r) => assert!(r.ref_location.ends_with("/User")),
544 _ => panic!("ref expected"),
545 }
546 }
547
548 #[test]
549 fn test_merge_path_items_get_post() {
550 let get = create_or_update_path_item(
551 None,
552 &http::Method::GET,
553 create_operation_with_doc(&http::Method::GET, "/a", None, None),
554 );
555 let post = create_or_update_path_item(
556 None,
557 &http::Method::POST,
558 create_operation_with_doc(&http::Method::POST, "/a", None, None),
559 );
560 let merged = merge_path_items(&get, &post);
561 assert!(merged.get.is_some());
562 assert!(merged.post.is_some());
563 }
564
565 #[test]
566 fn test_merge_prefers_first_for_same_method() {
567 let op1 = create_operation_with_doc(&http::Method::GET, "/a", None, None);
569 let mut item1 = PathItem::default();
570 item1.get = Some(op1);
571 let op2 = create_operation_with_doc(&http::Method::GET, "/a", None, None);
572 let mut item2 = PathItem::default();
573 item2.get = Some(op2);
574 let merged = merge_path_items(&item1, &item2);
575 assert!(merged.get.is_some());
576 }
579}