1use crate::extractor::{HttpMethod, RouteInfo};
2use crate::schema_generator::{Schema, SchemaGenerator};
3use log::debug;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7pub struct OpenApiBuilder {
9 info: Info,
11 paths: HashMap<String, PathItem>,
13 components: Components,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Info {
20 pub title: String,
22 pub version: String,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub description: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PathItem {
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub get: Option<Operation>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub post: Option<Operation>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub put: Option<Operation>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub delete: Option<Operation>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub patch: Option<Operation>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub options: Option<Operation>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub head: Option<Operation>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct Operation {
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub summary: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub description: Option<String>,
64 #[serde(rename = "operationId", skip_serializing_if = "Option::is_none")]
66 pub operation_id: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub parameters: Option<Vec<Parameter>>,
70 #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
72 pub request_body: Option<RequestBody>,
73 pub responses: HashMap<String, Response>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct Parameter {
80 pub name: String,
82 #[serde(rename = "in")]
84 pub location: String,
85 pub required: bool,
87 pub schema: Schema,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub description: Option<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct RequestBody {
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub description: Option<String>,
100 pub required: bool,
102 pub content: HashMap<String, MediaType>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct MediaType {
109 pub schema: Schema,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct Response {
116 pub description: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub content: Option<HashMap<String, MediaType>>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct Components {
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub schemas: Option<HashMap<String, Schema>>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct OpenApiDocument {
134 pub openapi: String,
136 pub info: Info,
138 pub paths: HashMap<String, PathItem>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub components: Option<Components>,
143}
144
145impl OpenApiBuilder {
146 pub fn new() -> Self {
148 debug!("Initializing OpenApiBuilder");
149 Self {
150 info: Info {
151 title: "Generated API".to_string(),
152 version: "1.0.0".to_string(),
153 description: Some("API documentation generated from Rust code".to_string()),
154 },
155 paths: HashMap::new(),
156 components: Components { schemas: None },
157 }
158 }
159
160 pub fn with_info(mut self, title: String, version: String, description: Option<String>) -> Self {
162 self.info = Info {
163 title,
164 version,
165 description,
166 };
167 self
168 }
169
170 pub fn add_route(&mut self, route: &RouteInfo, schema_gen: &mut SchemaGenerator) {
172 debug!("Adding route: {} {}", route.method_str(), route.path);
173
174 let openapi_path = Self::convert_path_format(&route.path);
176
177 let parameters = if route.parameters.is_empty() {
179 None
180 } else {
181 let params: Vec<Parameter> = route
182 .parameters
183 .iter()
184 .map(|p| {
185 let param_schema = schema_gen.generate_parameter_schema(p);
186 Parameter {
187 name: param_schema.name,
188 location: param_schema.location,
189 required: param_schema.required,
190 schema: param_schema.schema,
191 description: None,
192 }
193 })
194 .collect();
195 Some(params)
196 };
197
198 let request_body = route.request_body.as_ref().map(|type_info| {
200 let schema = schema_gen.generate_schema(type_info);
201 RequestBody {
202 description: Some("Request body".to_string()),
203 required: true,
204 content: {
205 let mut content = HashMap::new();
206 content.insert(
207 "application/json".to_string(),
208 MediaType { schema },
209 );
210 content
211 },
212 }
213 });
214
215 let response = if let Some(response_type) = &route.response_type {
217 let schema = schema_gen.generate_schema(response_type);
218 Response {
219 description: "Successful response".to_string(),
220 content: Some({
221 let mut content = HashMap::new();
222 content.insert(
223 "application/json".to_string(),
224 MediaType { schema },
225 );
226 content
227 }),
228 }
229 } else {
230 Response {
232 description: "Successful response".to_string(),
233 content: None,
234 }
235 };
236
237 let mut responses = HashMap::new();
238 responses.insert("200".to_string(), response);
239
240 let operation = Operation {
242 summary: Some(format!("{} {}", route.method_str(), route.path)),
243 description: None,
244 operation_id: Some(route.handler_name.clone()),
245 parameters,
246 request_body,
247 responses,
248 };
249
250 let path_item = self.paths.entry(openapi_path).or_insert_with(|| PathItem {
252 get: None,
253 post: None,
254 put: None,
255 delete: None,
256 patch: None,
257 options: None,
258 head: None,
259 });
260
261 match route.method {
262 HttpMethod::Get => path_item.get = Some(operation),
263 HttpMethod::Post => path_item.post = Some(operation),
264 HttpMethod::Put => path_item.put = Some(operation),
265 HttpMethod::Delete => path_item.delete = Some(operation),
266 HttpMethod::Patch => path_item.patch = Some(operation),
267 HttpMethod::Options => path_item.options = Some(operation),
268 HttpMethod::Head => path_item.head = Some(operation),
269 }
270 }
271
272 fn convert_path_format(path: &str) -> String {
274 let parts: Vec<&str> = path.split('/').collect();
277 let converted_parts: Vec<String> = parts
278 .iter()
279 .map(|part| {
280 if part.starts_with(':') {
281 format!("{{{}}}", &part[1..])
282 } else {
283 part.to_string()
284 }
285 })
286 .collect();
287
288 converted_parts.join("/")
289 }
290
291 pub fn build(self, schema_gen: SchemaGenerator) -> OpenApiDocument {
293 debug!("Building final OpenAPI document");
294
295 let schemas = schema_gen.get_schemas();
297 let components = if !schemas.is_empty() {
298 Some(Components {
299 schemas: Some(schemas.clone()),
300 })
301 } else {
302 None
303 };
304
305 OpenApiDocument {
306 openapi: "3.0.0".to_string(),
307 info: self.info,
308 paths: self.paths,
309 components,
310 }
311 }
312}
313
314impl Default for OpenApiBuilder {
315 fn default() -> Self {
316 Self::new()
317 }
318}
319
320impl RouteInfo {
321 fn method_str(&self) -> &str {
323 match self.method {
324 HttpMethod::Get => "GET",
325 HttpMethod::Post => "POST",
326 HttpMethod::Put => "PUT",
327 HttpMethod::Delete => "DELETE",
328 HttpMethod::Patch => "PATCH",
329 HttpMethod::Options => "OPTIONS",
330 HttpMethod::Head => "HEAD",
331 }
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use crate::extractor::{HttpMethod, Parameter, ParameterLocation, RouteInfo, TypeInfo};
339 use crate::parser::AstParser;
340 use crate::type_resolver::TypeResolver;
341 use std::fs;
342 use std::io::Write;
343 use tempfile::TempDir;
344
345 fn create_temp_file(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
347 let file_path = dir.path().join(name);
348 let mut file = fs::File::create(&file_path).unwrap();
349 file.write_all(content.as_bytes()).unwrap();
350 file_path
351 }
352
353 fn create_generator_from_code(code: &str) -> SchemaGenerator {
355 let temp_dir = TempDir::new().unwrap();
356 let file_path = create_temp_file(&temp_dir, "test.rs", code);
357 let parsed = AstParser::parse_file(&file_path).unwrap();
358 let type_resolver = TypeResolver::new(vec![parsed]);
359 SchemaGenerator::new(type_resolver)
360 }
361
362 #[test]
363 fn test_new_builder() {
364 let builder = OpenApiBuilder::new();
365
366 assert_eq!(builder.info.title, "Generated API");
367 assert_eq!(builder.info.version, "1.0.0");
368 assert!(builder.info.description.is_some());
369 assert!(builder.paths.is_empty());
370 }
371
372 #[test]
373 fn test_with_info() {
374 let builder = OpenApiBuilder::new()
375 .with_info(
376 "My API".to_string(),
377 "2.0.0".to_string(),
378 Some("Custom description".to_string()),
379 );
380
381 assert_eq!(builder.info.title, "My API");
382 assert_eq!(builder.info.version, "2.0.0");
383 assert_eq!(builder.info.description, Some("Custom description".to_string()));
384 }
385
386 #[test]
387 fn test_add_simple_get_route() {
388 let mut builder = OpenApiBuilder::new();
389 let mut schema_gen = create_generator_from_code("");
390
391 let route = RouteInfo::new(
392 "/users".to_string(),
393 HttpMethod::Get,
394 "get_users".to_string(),
395 );
396
397 builder.add_route(&route, &mut schema_gen);
398
399 assert_eq!(builder.paths.len(), 1);
400 assert!(builder.paths.contains_key("/users"));
401
402 let path_item = &builder.paths["/users"];
403 assert!(path_item.get.is_some());
404 assert!(path_item.post.is_none());
405
406 let operation = path_item.get.as_ref().unwrap();
407 assert_eq!(operation.operation_id, Some("get_users".to_string()));
408 assert!(operation.parameters.is_none());
409 assert!(operation.request_body.is_none());
410 assert!(operation.responses.contains_key("200"));
411 }
412
413 #[test]
414 fn test_add_post_route_with_request_body() {
415 let code = r#"
416 pub struct User {
417 pub id: u32,
418 pub name: String,
419 }
420 "#;
421
422 let mut builder = OpenApiBuilder::new();
423 let mut schema_gen = create_generator_from_code(code);
424
425 let mut route = RouteInfo::new(
426 "/users".to_string(),
427 HttpMethod::Post,
428 "create_user".to_string(),
429 );
430 route.request_body = Some(TypeInfo::new("User".to_string()));
431
432 builder.add_route(&route, &mut schema_gen);
433
434 let path_item = &builder.paths["/users"];
435 assert!(path_item.post.is_some());
436
437 let operation = path_item.post.as_ref().unwrap();
438 assert!(operation.request_body.is_some());
439
440 let request_body = operation.request_body.as_ref().unwrap();
441 assert!(request_body.required);
442 assert!(request_body.content.contains_key("application/json"));
443 }
444
445 #[test]
446 fn test_add_route_with_path_parameter() {
447 let mut builder = OpenApiBuilder::new();
448 let mut schema_gen = create_generator_from_code("");
449
450 let mut route = RouteInfo::new(
451 "/users/:id".to_string(),
452 HttpMethod::Get,
453 "get_user".to_string(),
454 );
455 route.parameters.push(Parameter::new(
456 "id".to_string(),
457 ParameterLocation::Path,
458 TypeInfo::new("u32".to_string()),
459 true,
460 ));
461
462 builder.add_route(&route, &mut schema_gen);
463
464 assert!(builder.paths.contains_key("/users/{id}"));
466
467 let path_item = &builder.paths["/users/{id}"];
468 let operation = path_item.get.as_ref().unwrap();
469
470 assert!(operation.parameters.is_some());
471 let parameters = operation.parameters.as_ref().unwrap();
472 assert_eq!(parameters.len(), 1);
473 assert_eq!(parameters[0].name, "id");
474 assert_eq!(parameters[0].location, "path");
475 assert!(parameters[0].required);
476 }
477
478 #[test]
479 fn test_add_route_with_query_parameter() {
480 let mut builder = OpenApiBuilder::new();
481 let mut schema_gen = create_generator_from_code("");
482
483 let mut route = RouteInfo::new(
484 "/users".to_string(),
485 HttpMethod::Get,
486 "list_users".to_string(),
487 );
488 route.parameters.push(Parameter::new(
489 "page".to_string(),
490 ParameterLocation::Query,
491 TypeInfo::new("i32".to_string()),
492 false,
493 ));
494
495 builder.add_route(&route, &mut schema_gen);
496
497 let path_item = &builder.paths["/users"];
498 let operation = path_item.get.as_ref().unwrap();
499
500 assert!(operation.parameters.is_some());
501 let parameters = operation.parameters.as_ref().unwrap();
502 assert_eq!(parameters.len(), 1);
503 assert_eq!(parameters[0].name, "page");
504 assert_eq!(parameters[0].location, "query");
505 assert!(!parameters[0].required);
506 }
507
508 #[test]
509 fn test_add_route_with_response_type() {
510 let code = r#"
511 pub struct User {
512 pub id: u32,
513 pub name: String,
514 }
515 "#;
516
517 let mut builder = OpenApiBuilder::new();
518 let mut schema_gen = create_generator_from_code(code);
519
520 let mut route = RouteInfo::new(
521 "/users/:id".to_string(),
522 HttpMethod::Get,
523 "get_user".to_string(),
524 );
525 route.response_type = Some(TypeInfo::new("User".to_string()));
526
527 builder.add_route(&route, &mut schema_gen);
528
529 let path_item = &builder.paths["/users/{id}"];
530 let operation = path_item.get.as_ref().unwrap();
531
532 let response = &operation.responses["200"];
533 assert_eq!(response.description, "Successful response");
534 assert!(response.content.is_some());
535
536 let content = response.content.as_ref().unwrap();
537 assert!(content.contains_key("application/json"));
538 }
539
540 #[test]
541 fn test_add_multiple_routes_same_path() {
542 let mut builder = OpenApiBuilder::new();
543 let mut schema_gen = create_generator_from_code("");
544
545 let get_route = RouteInfo::new(
546 "/users".to_string(),
547 HttpMethod::Get,
548 "list_users".to_string(),
549 );
550
551 let post_route = RouteInfo::new(
552 "/users".to_string(),
553 HttpMethod::Post,
554 "create_user".to_string(),
555 );
556
557 builder.add_route(&get_route, &mut schema_gen);
558 builder.add_route(&post_route, &mut schema_gen);
559
560 assert_eq!(builder.paths.len(), 1);
562
563 let path_item = &builder.paths["/users"];
564 assert!(path_item.get.is_some());
565 assert!(path_item.post.is_some());
566
567 assert_eq!(
568 path_item.get.as_ref().unwrap().operation_id,
569 Some("list_users".to_string())
570 );
571 assert_eq!(
572 path_item.post.as_ref().unwrap().operation_id,
573 Some("create_user".to_string())
574 );
575 }
576
577 #[test]
578 fn test_add_routes_different_methods() {
579 let mut builder = OpenApiBuilder::new();
580 let mut schema_gen = create_generator_from_code("");
581
582 let methods = vec![
583 (HttpMethod::Get, "get_handler"),
584 (HttpMethod::Post, "post_handler"),
585 (HttpMethod::Put, "put_handler"),
586 (HttpMethod::Delete, "delete_handler"),
587 (HttpMethod::Patch, "patch_handler"),
588 ];
589
590 for (method, handler) in methods {
591 let route = RouteInfo::new(
592 "/resource".to_string(),
593 method,
594 handler.to_string(),
595 );
596 builder.add_route(&route, &mut schema_gen);
597 }
598
599 let path_item = &builder.paths["/resource"];
600 assert!(path_item.get.is_some());
601 assert!(path_item.post.is_some());
602 assert!(path_item.put.is_some());
603 assert!(path_item.delete.is_some());
604 assert!(path_item.patch.is_some());
605 }
606
607 #[test]
608 fn test_convert_path_format_axum_style() {
609 let path = "/users/:id/posts/:post_id";
610 let converted = OpenApiBuilder::convert_path_format(path);
611 assert_eq!(converted, "/users/{id}/posts/{post_id}");
612 }
613
614 #[test]
615 fn test_convert_path_format_actix_style() {
616 let path = "/users/{id}/posts/{post_id}";
617 let converted = OpenApiBuilder::convert_path_format(path);
618 assert_eq!(converted, "/users/{id}/posts/{post_id}");
619 }
620
621 #[test]
622 fn test_convert_path_format_no_params() {
623 let path = "/users/list";
624 let converted = OpenApiBuilder::convert_path_format(path);
625 assert_eq!(converted, "/users/list");
626 }
627
628 #[test]
629 fn test_build_document_structure() {
630 let code = r#"
631 pub struct User {
632 pub id: u32,
633 pub name: String,
634 }
635 "#;
636
637 let mut builder = OpenApiBuilder::new();
638 let mut schema_gen = create_generator_from_code(code);
639
640 let mut route = RouteInfo::new(
641 "/users".to_string(),
642 HttpMethod::Post,
643 "create_user".to_string(),
644 );
645 route.request_body = Some(TypeInfo::new("User".to_string()));
646
647 builder.add_route(&route, &mut schema_gen);
648
649 let document = builder.build(schema_gen);
650
651 assert_eq!(document.openapi, "3.0.0");
652 assert_eq!(document.info.title, "Generated API");
653 assert_eq!(document.info.version, "1.0.0");
654 assert_eq!(document.paths.len(), 1);
655 assert!(document.components.is_some());
656
657 let components = document.components.unwrap();
658 assert!(components.schemas.is_some());
659
660 let schemas = components.schemas.unwrap();
661 assert!(schemas.contains_key("User"));
662 }
663
664 #[test]
665 fn test_build_document_with_multiple_schemas() {
666 let code = r#"
667 pub struct User {
668 pub id: u32,
669 pub profile: Profile,
670 }
671
672 pub struct Profile {
673 pub bio: String,
674 }
675 "#;
676
677 let mut builder = OpenApiBuilder::new();
678 let mut schema_gen = create_generator_from_code(code);
679
680 let mut route = RouteInfo::new(
681 "/users".to_string(),
682 HttpMethod::Post,
683 "create_user".to_string(),
684 );
685 route.request_body = Some(TypeInfo::new("User".to_string()));
686
687 builder.add_route(&route, &mut schema_gen);
688
689 let document = builder.build(schema_gen);
690
691 let components = document.components.unwrap();
692 let schemas = components.schemas.unwrap();
693
694 assert!(schemas.contains_key("User"));
696 assert!(schemas.contains_key("Profile"));
697 }
698
699 #[test]
700 fn test_build_document_no_schemas() {
701 let mut builder = OpenApiBuilder::new();
702 let mut schema_gen = create_generator_from_code("");
703
704 let route = RouteInfo::new(
705 "/health".to_string(),
706 HttpMethod::Get,
707 "health_check".to_string(),
708 );
709
710 builder.add_route(&route, &mut schema_gen);
711
712 let document = builder.build(schema_gen);
713
714 assert!(document.components.is_none());
716 }
717
718 #[test]
719 fn test_operation_summary_format() {
720 let mut builder = OpenApiBuilder::new();
721 let mut schema_gen = create_generator_from_code("");
722
723 let route = RouteInfo::new(
724 "/users/:id".to_string(),
725 HttpMethod::Get,
726 "get_user".to_string(),
727 );
728
729 builder.add_route(&route, &mut schema_gen);
730
731 let path_item = &builder.paths["/users/{id}"];
732 let operation = path_item.get.as_ref().unwrap();
733
734 assert_eq!(operation.summary, Some("GET /users/:id".to_string()));
735 }
736
737 #[test]
738 fn test_default_response_without_type() {
739 let mut builder = OpenApiBuilder::new();
740 let mut schema_gen = create_generator_from_code("");
741
742 let route = RouteInfo::new(
743 "/users".to_string(),
744 HttpMethod::Delete,
745 "delete_user".to_string(),
746 );
747
748 builder.add_route(&route, &mut schema_gen);
749
750 let path_item = &builder.paths["/users"];
751 let operation = path_item.delete.as_ref().unwrap();
752
753 let response = &operation.responses["200"];
754 assert_eq!(response.description, "Successful response");
755 assert!(response.content.is_none());
756 }
757
758 #[test]
759 fn test_complex_route_with_all_features() {
760 let code = r#"
761 pub struct CreateUserRequest {
762 pub name: String,
763 pub email: String,
764 }
765
766 pub struct User {
767 pub id: u32,
768 pub name: String,
769 pub email: String,
770 }
771 "#;
772
773 let mut builder = OpenApiBuilder::new();
774 let mut schema_gen = create_generator_from_code(code);
775
776 let mut route = RouteInfo::new(
777 "/users".to_string(),
778 HttpMethod::Post,
779 "create_user".to_string(),
780 );
781 route.request_body = Some(TypeInfo::new("CreateUserRequest".to_string()));
782 route.response_type = Some(TypeInfo::new("User".to_string()));
783 route.parameters.push(Parameter::new(
784 "api_key".to_string(),
785 ParameterLocation::Header,
786 TypeInfo::new("String".to_string()),
787 true,
788 ));
789
790 builder.add_route(&route, &mut schema_gen);
791
792 let path_item = &builder.paths["/users"];
793 let operation = path_item.post.as_ref().unwrap();
794
795 assert!(operation.parameters.is_some());
797 let parameters = operation.parameters.as_ref().unwrap();
798 assert_eq!(parameters.len(), 1);
799 assert_eq!(parameters[0].location, "header");
800
801 assert!(operation.request_body.is_some());
803
804 let response = &operation.responses["200"];
806 assert!(response.content.is_some());
807
808 let document = builder.build(schema_gen);
810 let schemas = document.components.unwrap().schemas.unwrap();
811 assert!(schemas.contains_key("CreateUserRequest"));
812 assert!(schemas.contains_key("User"));
813 }
814
815 #[test]
816 fn test_multiple_paths_in_document() {
817 let mut builder = OpenApiBuilder::new();
818 let mut schema_gen = create_generator_from_code("");
819
820 let routes = vec![
821 ("/users", HttpMethod::Get, "list_users"),
822 ("/users/:id", HttpMethod::Get, "get_user"),
823 ("/posts", HttpMethod::Get, "list_posts"),
824 ("/posts/:id", HttpMethod::Get, "get_post"),
825 ];
826
827 for (path, method, handler) in routes {
828 let route = RouteInfo::new(
829 path.to_string(),
830 method,
831 handler.to_string(),
832 );
833 builder.add_route(&route, &mut schema_gen);
834 }
835
836 let document = builder.build(schema_gen);
837
838 assert_eq!(document.paths.len(), 4);
839 assert!(document.paths.contains_key("/users"));
840 assert!(document.paths.contains_key("/users/{id}"));
841 assert!(document.paths.contains_key("/posts"));
842 assert!(document.paths.contains_key("/posts/{id}"));
843 }
844}