1use serde_json::{Value, json};
2use syn::spanned::Spanned;
3use syn::visit::{self, Visit};
4use syn::{Attribute, Expr, File, ImplItemFn, ItemEnum, ItemFn, ItemMod, ItemStruct, ItemType};
5
6#[derive(Debug)]
8pub enum ExtractedItem {
9 Schema {
11 name: Option<String>,
12 content: String,
13 line: usize,
14 },
15 Fragment {
17 name: String,
18 params: Vec<String>,
19 content: String,
20 line: usize,
21 },
22 Blueprint {
24 name: String,
25 params: Vec<String>,
26 content: String,
27 line: usize,
28 },
29 RouteDSL {
31 content: String,
32 line: usize,
33 operation_id: String,
34 },
35}
36
37#[derive(Default)]
38pub struct OpenApiVisitor {
39 pub items: Vec<ExtractedItem>,
40 pub current_tags: Vec<String>,
41}
42
43impl OpenApiVisitor {
44 fn check_attributes(
47 &mut self,
48 attrs: &[Attribute],
49 item_ident: Option<String>,
50 item_line: usize,
51 ) {
52 let doc_lines = crate::doc_parser::extract_doc_comments(attrs);
53
54 let has_openapi = doc_lines.iter().any(|l| l.contains("@openapi"));
55
56 if !has_openapi {
58 return;
59 }
60
61 let full_doc = doc_lines.join("\n");
62 self.parse_doc_block(&full_doc, item_ident, item_line);
63 }
64
65 fn parse_doc_block(&mut self, doc: &str, item_ident: Option<String>, line: usize) {
66 let lines: Vec<&str> = doc.lines().collect();
67 let min_indent = lines
69 .iter()
70 .filter(|line| !line.trim().is_empty())
71 .map(|line| line.chars().take_while(|c| *c == ' ').count())
72 .min()
73 .unwrap_or(0);
74
75 let unindented: Vec<String> = lines
76 .into_iter()
77 .map(|l| {
78 if l.len() >= min_indent {
79 l[min_indent..].to_string()
80 } else {
81 l.to_string()
82 }
83 })
84 .collect();
85 let content = unindented.join("\n");
86
87 let mut sections = Vec::new();
88 let mut current_header = String::new();
89 let mut current_body = Vec::new();
90
91 for line in content.lines() {
92 let trimmed = line.trim();
93 if trimmed.starts_with("@openapi") {
94 if !current_header.is_empty() || !current_body.is_empty() {
95 sections.push((current_header.clone(), current_body.join("\n")));
96 }
97 current_header = trimmed.to_string();
98 current_body.clear();
99 } else if trimmed.starts_with('{') && current_header.is_empty() {
100 if !current_header.is_empty() || !current_body.is_empty() {
101 sections.push((current_header.clone(), current_body.join("\n")));
102 }
103 current_header = "@json".to_string();
104 current_body.push(line.to_string());
105 } else {
106 current_body.push(line.to_string());
107 }
108 }
109 if !current_header.is_empty() || !current_body.is_empty() {
110 sections.push((current_header, current_body.join("\n")));
111 }
112
113 for (header, body) in sections {
114 let mut body_content = body.trim().to_string();
115
116 if header.starts_with("@openapi-fragment") {
117 let rest = header.strip_prefix("@openapi-fragment").unwrap().trim();
118 let (name, params) = if let Some(idx) = rest.find('(') {
119 let name = rest[..idx].trim().to_string();
120 let params_str = rest[idx + 1..].trim_end_matches(')');
121 let params: Vec<String> = params_str
122 .split(',')
123 .map(|p| p.trim().to_string())
124 .filter(|p| !p.is_empty())
125 .collect();
126 (name, params)
127 } else {
128 (rest.to_string(), Vec::new())
129 };
130
131 self.items.push(ExtractedItem::Fragment {
132 name,
133 params,
134 content: body_content,
135 line,
136 });
137 } else if header.starts_with("@openapi-type") {
138 let name = header
139 .strip_prefix("@openapi-type")
140 .unwrap()
141 .trim()
142 .to_string();
143 let wrapped = wrap_in_schema(&name, &body_content);
145 self.items.push(ExtractedItem::Schema {
146 name: Some(name),
147 content: wrapped,
148 line,
149 });
150 } else if header.starts_with("@openapi") && header.contains('<') {
151 if let Some(start) = header.find('<') {
152 if let Some(end) = header.rfind('>') {
153 let params_str = &header[start + 1..end];
154 let params: Vec<String> = params_str
155 .split(',')
156 .map(|p| p.trim().to_string())
157 .filter(|p| !p.is_empty())
158 .collect();
159
160 if let Some(ident) = &item_ident {
161 self.items.push(ExtractedItem::Blueprint {
162 name: ident.clone(),
163 params,
164 content: body_content,
165 line,
166 });
167 }
168 }
169 }
170 } else if (header.starts_with("@openapi") && !header.contains('<'))
171 || header == "@json"
172 || header.is_empty()
173 {
174 if !self.current_tags.is_empty() {
176 let tags_yaml_list = self
177 .current_tags
178 .iter()
179 .map(|t| format!("- {}", t))
180 .collect::<Vec<_>>();
181
182 let verbs = [
183 "get:", "post:", "put:", "delete:", "patch:", "head:", "options:", "trace:",
184 ];
185 let mut new_lines = Vec::new();
186 let mut injected_any = false;
187
188 for line in body_content.lines() {
189 new_lines.push(line.to_string());
190 let trimmed = line.trim();
191 if verbs.contains(&trimmed) {
192 let indent = line.chars().take_while(|c| *c == ' ').count();
193 let child_indent = " ".repeat(indent + 2);
194
195 if !body_content.contains("tags:") {
196 new_lines.push(format!("{}tags:", child_indent));
197 for tag in &tags_yaml_list {
198 new_lines.push(format!("{} {}", child_indent, tag));
199 }
200 injected_any = true;
201 }
202 }
203 }
204
205 if injected_any {
206 body_content = new_lines.join("\n");
207 }
208 }
209
210 let starts_with_toplevel = body_content.lines().any(|line| {
212 let trimmed = line.trim();
213 if trimmed.starts_with("#") {
214 return false;
215 }
216 if let Some(key) = trimmed.split(':').next() {
217 matches!(
218 key.trim(),
219 "openapi"
220 | "info"
221 | "paths"
222 | "components"
223 | "tags"
224 | "servers"
225 | "security"
226 )
227 } else {
228 false
229 }
230 });
231
232 let final_content = if !starts_with_toplevel && !body_content.trim().is_empty() {
233 if let Some(n) = &item_ident {
234 wrap_in_schema(n, &body_content)
235 } else {
236 body_content
237 }
238 } else {
239 body_content
240 };
241
242 self.items.push(ExtractedItem::Schema {
243 name: item_ident.clone(),
244 content: final_content,
245 line,
246 });
247 }
248 }
249 }
250 fn process_struct_field(
252 field: &syn::Field,
253 rename_rule: &Option<String>,
254 ) -> (String, Value, bool) {
255 let default_field_name = field.ident.as_ref().unwrap().to_string();
256
257 let (mut field_final_name, field_desc, _, field_doc_lines, _, _) =
259 crate::doc_parser::extract_naming_and_doc(&field.attrs, &default_field_name);
260
261 if field_final_name == default_field_name {
265 if let Some(rule) = rename_rule {
266 field_final_name = crate::doc_parser::apply_casing(&field_final_name, rule);
267 }
268 }
269
270 let (mut field_schema, is_required) = map_syn_type_to_openapi(&field.ty);
271
272 if !field_desc.is_empty() {
274 if let Value::Object(map) = &mut field_schema {
275 map.insert("description".to_string(), Value::String(field_desc));
276 }
277 }
278
279 let validation_props = crate::doc_parser::extract_validation(&field.attrs);
281 if !validation_props.as_object().unwrap().is_empty() {
282 json_merge(&mut field_schema, validation_props);
283 }
284
285 let mut field_openapi_lines = Vec::new();
287 let mut collecting_openapi = false;
288 for line in &field_doc_lines {
289 let trimmed = line.trim();
290 if trimmed.starts_with("@openapi") {
291 collecting_openapi = true;
292 let rest = trimmed.strip_prefix("@openapi").unwrap().trim();
293 if !rest.is_empty() && !rest.starts_with("rename") {
294 field_openapi_lines.push(rest.to_string());
295 }
296 } else if collecting_openapi {
297 field_openapi_lines.push(line.to_string());
298 }
299 }
300
301 if !field_openapi_lines.is_empty() {
302 let override_yaml = field_openapi_lines.join("\n");
303 match serde_yaml::from_str::<Value>(&override_yaml) {
304 Ok(override_val) => {
305 if !override_val.is_null() {
306 json_merge(&mut field_schema, override_val);
307 }
308 }
309 Err(e) => {
310 log::warn!(
311 "Failed to parse @openapi override for field '{}': {}",
312 default_field_name,
313 e
314 );
315 }
316 }
317 }
318
319 (field_final_name, field_schema, is_required)
320 }
321 fn process_enum_variant(
322 variant: &syn::Variant,
323 rename_rule: &Option<String>,
324 ) -> Option<String> {
325 if !matches!(variant.fields, syn::Fields::Unit) {
326 return None;
327 }
328 let default_variant_name = variant.ident.to_string();
329 let (mut variant_final_name, _, _, _, _, _) =
331 crate::doc_parser::extract_naming_and_doc(&variant.attrs, &default_variant_name);
332
333 if variant_final_name == default_variant_name {
335 if let Some(rule) = rename_rule {
336 variant_final_name = crate::doc_parser::apply_casing(&variant_final_name, rule);
337 }
338 }
339 Some(variant_final_name)
340 }
341}
342
343fn wrap_in_schema(name: &str, content: &str) -> String {
345 let indented = content
346 .lines()
347 .map(|l| format!(" {}", l))
348 .collect::<Vec<_>>()
349 .join("\n");
350 format!("components:\n schemas:\n {}:\n{}", name, indented)
351}
352
353pub use crate::type_mapper::map_syn_type_to_openapi;
354
355pub fn json_merge(a: &mut Value, b: Value) {
357 match (a, b) {
358 (Value::Object(a), Value::Object(b)) => {
359 for (k, v) in b {
360 json_merge(a.entry(k).or_insert(Value::Null), v);
361 }
362 }
363 (a, b) => *a = b,
364 }
365}
366
367impl<'ast> Visit<'ast> for OpenApiVisitor {
368 fn visit_file(&mut self, i: &'ast File) {
369 let mut current_block_type: Option<String> = None;
371 let mut current_block_lines = Vec::new();
372 let mut start_line = 1;
373
374 for attr in &i.attrs {
376 if attr.path().is_ident("doc") {
377 if let syn::Meta::NameValue(meta) = &attr.meta {
378 if let Expr::Lit(expr_lit) = &meta.value {
379 if let syn::Lit::Str(lit_str) = &expr_lit.lit {
380 let raw_line = lit_str.value();
381 let trimmed = raw_line.trim();
382
383 if trimmed.starts_with("@openapi-type") {
384 if !current_block_lines.is_empty() {
386 let body = current_block_lines.join("\n");
387 if let Some(name) = current_block_type.take() {
388 let wrapped = wrap_in_schema(&name, &body);
389 self.items.push(ExtractedItem::Schema {
390 name: Some(name),
391 content: wrapped,
392 line: start_line,
393 });
394 } else {
395 self.parse_doc_block(&body, None, start_line);
397 }
398 current_block_lines.clear();
399 }
400
401 if let Some(name) = trimmed.strip_prefix("@openapi-type") {
403 current_block_type = Some(name.trim().to_string());
404 start_line = attr.span().start().line;
405 }
406 } else if trimmed.starts_with("@openapi") {
407 if !current_block_lines.is_empty() {
409 let body = current_block_lines.join("\n");
410 if let Some(name) = current_block_type.take() {
411 let wrapped = wrap_in_schema(&name, &body);
412 self.items.push(ExtractedItem::Schema {
413 name: Some(name),
414 content: wrapped,
415 line: start_line,
416 });
417 } else {
418 self.parse_doc_block(&body, None, start_line);
419 }
420 current_block_lines.clear();
421 }
422
423 current_block_type = None;
425 start_line = attr.span().start().line;
426 current_block_lines.push(raw_line); } else if trimmed.starts_with("@route") {
428 if !current_block_lines.is_empty() && current_block_type.is_some() {
430 let body = current_block_lines.join("\n");
432 if let Some(name) = current_block_type.take() {
433 let wrapped = wrap_in_schema(&name, &body);
434 self.items.push(ExtractedItem::Schema {
435 name: Some(name),
436 content: wrapped,
437 line: start_line,
438 });
439 }
440 current_block_lines.clear();
441 start_line = attr.span().start().line;
442 } else if current_block_lines.is_empty() {
443 start_line = attr.span().start().line;
444 }
445
446 current_block_type = None;
447 current_block_lines.push(raw_line);
448 } else if !current_block_lines.is_empty()
449 || current_block_type.is_some()
450 {
451 current_block_lines.push(raw_line);
452 }
453 }
454 }
455 }
456 } else {
457 if !current_block_lines.is_empty() {
459 let body = current_block_lines.join("\n");
460 if let Some(name) = current_block_type.take() {
461 let wrapped = wrap_in_schema(&name, &body);
462 self.items.push(ExtractedItem::Schema {
463 name: Some(name),
464 content: wrapped,
465 line: start_line,
466 });
467 } else {
468 if body.contains("@route") {
470 self.items.push(ExtractedItem::RouteDSL {
471 content: body,
472 line: start_line,
473 operation_id: format!("virtual_route_{}", start_line),
474 });
475 } else {
476 self.parse_doc_block(&body, None, start_line);
477 }
478 }
479 current_block_lines.clear();
480 }
481 }
482 }
483
484 if !current_block_lines.is_empty() {
486 let body = current_block_lines.join("\n");
487 if let Some(name) = current_block_type {
488 let wrapped = wrap_in_schema(&name, &body);
489 self.items.push(ExtractedItem::Schema {
490 name: Some(name),
491 content: wrapped,
492 line: start_line,
493 });
494 } else {
495 if body.contains("@route") {
497 self.items.push(ExtractedItem::RouteDSL {
498 content: body,
499 line: start_line,
500 operation_id: format!("virtual_route_{}", start_line),
501 });
502 } else {
503 self.parse_doc_block(&body, None, start_line);
504 }
505 }
506 }
507
508 visit::visit_file(self, i);
509 }
510
511 fn visit_item_fn(&mut self, i: &'ast ItemFn) {
512 let mut doc_lines = Vec::new();
513 for attr in &i.attrs {
514 if attr.path().is_ident("doc") {
515 if let syn::Meta::NameValue(meta) = &attr.meta {
516 if let Expr::Lit(expr_lit) = &meta.value {
517 if let syn::Lit::Str(lit_str) = &expr_lit.lit {
518 doc_lines.push(lit_str.value());
519 }
520 }
521 }
522 }
523 }
524
525 let has_route = doc_lines.iter().any(|l| l.trim().starts_with("@route"));
527
528 if !has_route {
529 self.check_attributes(&i.attrs, None, i.span().start().line);
531 visit::visit_item_fn(self, i);
532 return;
533 }
534
535 let content = doc_lines.join("\n");
537 self.items.push(ExtractedItem::RouteDSL {
538 content,
539 line: i.span().start().line,
540 operation_id: i.sig.ident.to_string(),
541 });
542
543 visit::visit_item_fn(self, i);
544 }
545
546 fn visit_item_type(&mut self, i: &'ast ItemType) {
547 let ident = i.ident.to_string();
548 let (mut schema, _) = map_syn_type_to_openapi(&i.ty);
549
550 let mut desc_lines = Vec::new();
552 let mut openapi_lines = Vec::new();
553 let mut collecting_openapi = false;
554
555 for attr in &i.attrs {
556 if attr.path().is_ident("doc") {
557 if let syn::Meta::NameValue(meta) = &attr.meta {
558 if let Expr::Lit(expr_lit) = &meta.value {
559 if let syn::Lit::Str(lit_str) = &expr_lit.lit {
560 let val = lit_str.value();
561 let trimmed = val.trim();
562
563 if trimmed.starts_with("@openapi") {
564 collecting_openapi = true;
565 let rest = trimmed.strip_prefix("@openapi").unwrap().trim();
566 if !rest.is_empty() {
567 openapi_lines.push(rest.to_string());
568 }
569 } else if collecting_openapi {
570 openapi_lines.push(val.to_string());
571 } else {
572 desc_lines.push(val.trim().to_string());
573 }
574 }
575 }
576 }
577 } else {
578 collecting_openapi = false;
579 }
580 }
581
582 if !desc_lines.is_empty() {
583 let desc_str = desc_lines.join(" ");
584 if let Value::Object(map) = &mut schema {
585 map.insert("description".to_string(), Value::String(desc_str));
586 }
587 }
588
589 if !openapi_lines.is_empty() {
590 let override_yaml = openapi_lines.join("\n");
591 if let Ok(override_val) = serde_yaml::from_str::<Value>(&override_yaml) {
592 if !override_val.is_null() {
593 json_merge(&mut schema, override_val);
594 }
595 }
596 }
597
598 if let Ok(generated) = serde_yaml::to_string(&schema) {
599 let trimmed = generated.trim_start_matches("---\n").to_string();
600 let wrapped = wrap_in_schema(&ident, &trimmed);
601 self.items.push(ExtractedItem::Schema {
602 name: Some(ident),
603 content: wrapped,
604 line: i.span().start().line,
605 });
606 }
607
608 visit::visit_item_type(self, i);
609 }
610
611 fn visit_item_struct(&mut self, i: &'ast ItemStruct) {
612 let default_name = i.ident.to_string();
614 let (final_name, struct_desc, rename_rule, doc_lines, _, _) =
615 crate::doc_parser::extract_naming_and_doc(&i.attrs, &default_name);
616
617 if !doc_lines.iter().any(|l| l.contains("@openapi")) {
619 visit::visit_item_struct(self, i);
620 return;
621 }
622
623 let mut properties = serde_json::Map::new();
624 let mut required_fields = Vec::new();
625 let mut has_fields = false;
626
627 if let syn::Fields::Named(fields) = &i.fields {
628 for field in &fields.named {
629 has_fields = true;
630 let (field_final_name, field_schema, is_required) =
631 Self::process_struct_field(field, &rename_rule);
632
633 properties.insert(field_final_name.clone(), field_schema);
634 if is_required {
635 required_fields.push(field_final_name);
636 }
637 }
638 }
639
640 let mut schema = if has_fields {
642 let mut s = json!({
643 "type": "object",
644 "properties": properties
645 });
646 if !required_fields.is_empty() {
647 if let Value::Object(map) = &mut s {
648 map.insert("required".to_string(), json!(required_fields));
649 }
650 }
651 s
652 } else {
653 json!({ "type": "object" })
655 };
656
657 if !struct_desc.is_empty() {
659 json_merge(&mut schema, json!({ "description": struct_desc }));
660 }
661
662 let mut openapi_lines = Vec::new();
664 let mut collecting_openapi = false;
665 let mut blueprint_params: Option<Vec<String>> = None;
666
667 for line in &doc_lines {
668 let trimmed = line.trim();
669 if trimmed.starts_with("@openapi") {
670 collecting_openapi = true;
671 let rest = trimmed.strip_prefix("@openapi").unwrap().trim();
672
673 if !rest.is_empty() && !rest.starts_with("rename") && !rest.starts_with("-type") {
674 if rest.contains('<') {
675 if let Some(start) = rest.find('<') {
677 if let Some(end) = rest.rfind('>') {
678 let params_str = &rest[start + 1..end];
679 blueprint_params = Some(
680 params_str
681 .split(',')
682 .map(|p| p.trim().to_string())
683 .filter(|p| !p.is_empty())
684 .collect(),
685 );
686
687 let after_gt = rest[end + 1..].trim();
688 if !after_gt.is_empty() {
689 openapi_lines.push(after_gt.to_string());
690 }
691 }
692 }
693 } else {
694 openapi_lines.push(rest.to_string());
695 }
696 }
697 } else if collecting_openapi {
698 openapi_lines.push(line.to_string());
699 }
700 }
701
702 if !openapi_lines.is_empty() {
703 let override_yaml = openapi_lines.join("\n");
704 match serde_yaml::from_str::<Value>(&override_yaml) {
705 Ok(override_val) => {
706 if !override_val.is_null() {
707 json_merge(&mut schema, override_val);
708 }
709 }
710 Err(e) => {
711 log::warn!(
712 "Failed to parse @openapi override for struct '{}': {}",
713 final_name,
714 e
715 );
716 }
717 }
718 }
719
720 match serde_yaml::to_string(&schema) {
722 Ok(generated) => {
723 let trimmed = generated.trim_start_matches("---\n").to_string();
724
725 if let Some(params) = blueprint_params {
726 self.items.push(ExtractedItem::Blueprint {
727 name: final_name,
728 params,
729 content: trimmed,
730 line: i.span().start().line,
731 });
732 } else {
733 let wrapped = wrap_in_schema(&final_name, &trimmed);
734 self.items.push(ExtractedItem::Schema {
735 name: Some(final_name),
736 content: wrapped,
737 line: i.span().start().line,
738 });
739 }
740 }
741 Err(e) => {
742 log::error!(
743 "Failed to serialize schema for struct '{}': {}",
744 default_name,
745 e
746 );
747 }
748 }
749
750 visit::visit_item_struct(self, i);
751 }
752
753 fn visit_item_enum(&mut self, i: &'ast ItemEnum) {
754 let default_name = i.ident.to_string();
756 let (final_name, enum_desc, rename_rule, doc_lines, serde_tag, serde_content) =
757 crate::doc_parser::extract_naming_and_doc(&i.attrs, &default_name);
758
759 if !doc_lines.iter().any(|l| l.contains("@openapi")) {
761 visit::visit_item_enum(self, i);
762 return;
763 }
764
765 if let Some(tag_prop) = serde_tag {
767 let mut variant_refs = Vec::new();
770 let mut mapping = serde_json::Map::new();
771
772 for v in &i.variants {
773 let default_variant_name = v.ident.to_string();
774 let (variant_final_value, variant_desc, _, _, _, _) =
775 crate::doc_parser::extract_naming_and_doc(&v.attrs, &default_variant_name);
776
777 let tag_value = if variant_final_value == default_variant_name {
779 if let Some(rule) = &rename_rule {
780 crate::doc_parser::apply_casing(&variant_final_value, rule)
781 } else {
782 variant_final_value
783 }
784 } else {
785 variant_final_value
786 };
787
788 let variant_schema_name = format!("{}{}", final_name, v.ident);
790 let variant_ref = format!("#/components/schemas/{}", variant_schema_name);
791
792 variant_refs.push(json!({ "$ref": variant_ref }));
793 mapping.insert(tag_value.clone(), json!(variant_ref));
794
795 let mut properties = serde_json::Map::new();
797 let mut required = vec![tag_prop.clone()];
798
799 properties.insert(
801 tag_prop.clone(),
802 json!({
803 "type": "string",
804 "enum": [tag_value],
805 "description": format!("Discriminator: {}", tag_value)
806 }),
807 );
808
809 let mut content_schema = None;
811
812 if let syn::Fields::Named(fields) = &v.fields {
813 let mut inner_props = serde_json::Map::new();
814 let mut inner_req = Vec::new();
815
816 for field in &fields.named {
817 let (f_name, f_schema, f_req) =
818 Self::process_struct_field(field, &rename_rule);
819 inner_props.insert(f_name.clone(), f_schema);
820 if f_req {
821 inner_req.push(f_name);
822 }
823 }
824 content_schema = Some(json!({
825 "type": "object",
826 "properties": inner_props,
827 "required": inner_req
828 }));
829 } else if let syn::Fields::Unnamed(fields) = &v.fields {
830 if fields.unnamed.len() == 1 {
832 let field = &fields.unnamed[0];
833 let (mut schema, _) =
834 crate::type_mapper::map_syn_type_to_openapi(&field.ty);
835
836 let validation = crate::doc_parser::extract_validation(&field.attrs);
838 if validation.is_object() {
839 crate::visitor::json_merge(&mut schema, validation);
840 }
841
842 content_schema = Some(schema);
843 } else {
844 log::warn!(
845 "Tuple variants with >1 fields in tagged enums are complex. Skipping fields for {}",
846 default_variant_name
847 );
848 }
849 }
850
851 if let Some(content_prop) = &serde_content {
852 if let Some(inner) = content_schema {
854 properties.insert(content_prop.clone(), inner);
855 required.push(content_prop.clone());
856 }
857 } else {
858 if let Some(inner) = content_schema {
862 if let Some(props) = inner.get("properties").and_then(|p| p.as_object()) {
863 for (k, v) in props {
864 properties.insert(k.clone(), v.clone());
865 }
866 }
867 if let Some(req) = inner.get("required").and_then(|r| r.as_array()) {
868 for r in req {
869 if let Some(s) = r.as_str() {
870 required.push(s.to_string());
871 }
872 }
873 }
874 }
875 }
876
877 let mut variant_schema = json!({
878 "type": "object",
879 "properties": properties,
880 "required": required
881 });
882
883 if !variant_desc.is_empty() {
884 json_merge(&mut variant_schema, json!({ "description": variant_desc }));
885 }
886
887 if let Ok(generated) = serde_yaml::to_string(&variant_schema) {
889 let trimmed = generated.trim_start_matches("---\n").to_string();
890 let wrapped = wrap_in_schema(&variant_schema_name, &trimmed);
891 self.items.push(ExtractedItem::Schema {
892 name: Some(variant_schema_name),
893 content: wrapped,
894 line: v.span().start().line,
895 });
896 }
897 }
898
899 let mut main_schema = json!({
901 "type": "object",
902 "oneOf": variant_refs,
903 "discriminator": {
904 "propertyName": tag_prop,
905 "mapping": mapping
906 }
907 });
908
909 if !enum_desc.is_empty() {
910 json_merge(&mut main_schema, json!({ "description": enum_desc }));
911 }
912
913 if let Ok(generated) = serde_yaml::to_string(&main_schema) {
915 let trimmed = generated.trim_start_matches("---\n").to_string();
916 let wrapped = wrap_in_schema(&final_name, &trimmed);
917 self.items.push(ExtractedItem::Schema {
918 name: Some(final_name),
919 content: wrapped,
920 line: i.span().start().line,
921 });
922 }
923
924 visit::visit_item_enum(self, i);
925 return;
926 }
927
928 let mut variants = Vec::new();
930 for v in &i.variants {
931 if let Some(variant_name) = Self::process_enum_variant(v, &rename_rule) {
932 variants.push(variant_name);
933 }
934 }
935
936 let mut schema = if !variants.is_empty() {
937 json!({
938 "type": "string",
939 "enum": variants
940 })
941 } else {
942 json!({ "type": "string" }) };
944
945 if !enum_desc.is_empty() {
947 json_merge(&mut schema, json!({ "description": enum_desc }));
948 }
949
950 let mut openapi_lines = Vec::new();
952 let mut collecting_openapi = false;
953 let mut blueprint_params: Option<Vec<String>> = None;
954
955 for line in &doc_lines {
956 let trimmed = line.trim();
957 if trimmed.starts_with("@openapi") {
958 collecting_openapi = true;
959 let rest = trimmed.strip_prefix("@openapi").unwrap().trim();
960
961 if !rest.is_empty() && !rest.starts_with("rename") && !rest.starts_with("-type") {
962 if rest.contains('<') {
963 if let Some(start) = rest.find('<') {
965 if let Some(end) = rest.rfind('>') {
966 let params_str = &rest[start + 1..end];
967 blueprint_params = Some(
968 params_str
969 .split(',')
970 .map(|p| p.trim().to_string())
971 .filter(|p| !p.is_empty())
972 .collect(),
973 );
974
975 let after_gt = rest[end + 1..].trim();
976 if !after_gt.is_empty() {
977 openapi_lines.push(after_gt.to_string());
978 }
979 }
980 }
981 } else {
982 openapi_lines.push(rest.to_string());
983 }
984 }
985 } else if collecting_openapi {
986 openapi_lines.push(line.to_string());
987 }
988 }
989
990 if !openapi_lines.is_empty() {
991 let override_yaml = openapi_lines.join("\n");
992 match serde_yaml::from_str::<Value>(&override_yaml) {
993 Ok(override_val) => {
994 if !override_val.is_null() {
995 json_merge(&mut schema, override_val);
996 }
997 }
998 Err(e) => {
999 log::warn!(
1000 "Failed to parse @openapi override for enum '{}': {}",
1001 final_name,
1002 e
1003 );
1004 }
1005 }
1006 }
1007
1008 if !variants.is_empty() || !openapi_lines.is_empty() {
1010 if let Ok(generated) = serde_yaml::to_string(&schema) {
1011 let trimmed = generated.trim_start_matches("---\n").to_string();
1012
1013 if let Some(params) = blueprint_params {
1014 self.items.push(ExtractedItem::Blueprint {
1015 name: final_name,
1016 params,
1017 content: trimmed,
1018 line: i.span().start().line,
1019 });
1020 } else {
1021 let wrapped = wrap_in_schema(&final_name, &trimmed);
1022 self.items.push(ExtractedItem::Schema {
1023 name: Some(final_name),
1024 content: wrapped,
1025 line: i.span().start().line,
1026 });
1027 }
1028 }
1029 }
1030
1031 visit::visit_item_enum(self, i);
1032 }
1033
1034 fn visit_item_mod(&mut self, i: &'ast ItemMod) {
1035 let mut found_tags = Vec::new();
1036 for attr in &i.attrs {
1037 if attr.path().is_ident("doc") {
1038 if let syn::Meta::NameValue(meta) = &attr.meta {
1039 if let Expr::Lit(expr_lit) = &meta.value {
1040 if let syn::Lit::Str(lit_str) = &expr_lit.lit {
1041 let val = lit_str.value();
1042 if val.contains("tags:") {
1043 if let Some(start) = val.find('[') {
1044 if let Some(end) = val.find(']') {
1045 let content = &val[start + 1..end];
1046 for t in content.split(',') {
1047 found_tags.push(t.trim().to_string());
1048 }
1049 }
1050 }
1051 }
1052 }
1053 }
1054 }
1055 }
1056 }
1057
1058 let old_len = self.current_tags.len();
1059 self.current_tags.extend(found_tags);
1060
1061 self.check_attributes(&i.attrs, None, i.span().start().line);
1062 visit::visit_item_mod(self, i);
1063
1064 self.current_tags.truncate(old_len);
1065 }
1066
1067 fn visit_impl_item_fn(&mut self, i: &'ast ImplItemFn) {
1068 self.check_attributes(&i.attrs, None, i.span().start().line);
1069 visit::visit_impl_item_fn(self, i);
1070 }
1071}
1072
1073pub fn extract_from_file(path: std::path::PathBuf) -> crate::error::Result<Vec<ExtractedItem>> {
1074 let content = std::fs::read_to_string(&path)?;
1075 let parsed_file = syn::parse_file(&content).map_err(|e| crate::error::Error::Parse {
1076 file: path.clone(),
1077 source: e,
1078 })?;
1079
1080 let mut visitor = OpenApiVisitor::default();
1081 visitor.visit_file(&parsed_file);
1082
1083 Ok(visitor.items)
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 use super::*;
1089
1090 #[test]
1091 fn test_struct_reflection() {
1092 let code = r#"
1093 /// @openapi
1094 struct MyStruct {
1095 pub id: String,
1096 pub count: i32,
1097 pub active: bool,
1098 pub tags: Vec<String>,
1099 pub meta: Option<String>
1100 }
1101 "#;
1102 let item_struct: ItemStruct = syn::parse_str(code).expect("Failed to parse struct");
1103
1104 let mut visitor = OpenApiVisitor::default();
1105 visitor.visit_item_struct(&item_struct);
1106
1107 assert_eq!(visitor.items.len(), 1);
1108 match &visitor.items[0] {
1109 ExtractedItem::Schema { name, content, .. } => {
1110 assert_eq!(name.as_ref().unwrap(), "MyStruct");
1111 assert!(content.contains("type: object"));
1113 assert!(content.contains("properties"));
1114 assert!(content.contains("id"));
1115 assert!(content.contains("type: string"));
1116 assert!(content.contains("count"));
1117 assert!(content.contains("type: integer"));
1118
1119 assert!(content.contains("tags"));
1121 assert!(content.contains("type: array"));
1122
1123 assert!(content.contains("required"));
1125 assert!(content.contains("id"));
1126 assert!(content.contains("count"));
1127 assert!(content.contains("tags"));
1128 }
1130 _ => panic!("Expected Schema"),
1131 }
1132 }
1133
1134 #[test]
1135 fn test_module_tags() {
1136 let code = r#"
1137 /// @openapi
1138 /// tags: [GroupA]
1139 mod my_mod {
1140 /// @openapi
1141 /// paths:
1142 /// /test:
1143 /// get:
1144 /// description: op
1145 fn my_fn() {}
1146 }
1147 "#;
1148 let item_mod: ItemMod = syn::parse_str(code).expect("Failed to parse mod");
1149
1150 let mut visitor = OpenApiVisitor::default();
1151 visitor.visit_item_mod(&item_mod);
1152
1153 assert_eq!(visitor.items.len(), 2);
1154 match &visitor.items[1] {
1155 ExtractedItem::Schema { content, .. } => {
1156 assert!(
1157 content.contains("tags:"),
1158 "Function should have tags injected"
1159 );
1160 assert!(content.contains("- GroupA"));
1161 assert!(content.contains("/test:"));
1162 }
1163 _ => panic!("Expected Schema"),
1164 }
1165 }
1166
1167 #[test]
1168 fn test_complex_types_and_docs() {
1169 let code = r#"
1170 /// @openapi
1171 struct Complex {
1172 /// Primary Identifier
1173 pub id: Uuid,
1174 /// @openapi example: "user@example.com"
1175 pub email: String,
1176 pub created_at: DateTime<Utc>,
1177 pub metadata: HashMap<String, String>,
1178 pub scores: Vec<f64>,
1179 pub config: Option<serde_json::Value>
1180 }
1181 "#;
1182 let item_struct: ItemStruct = syn::parse_str(code).expect("Failed to parse struct");
1183
1184 let mut visitor = OpenApiVisitor::default();
1185 visitor.visit_item_struct(&item_struct);
1186
1187 match &visitor.items[0] {
1188 ExtractedItem::Schema { content, .. } => {
1189 assert!(
1191 content.contains("description: Primary Identifier"),
1192 "Should merge doc comments"
1193 );
1194
1195 assert!(
1197 content.contains("example: user@example.com"),
1198 "Should merge @openapi attributes"
1199 );
1200
1201 assert!(content.contains("format: uuid"));
1203 assert!(content.contains("format: date-time"));
1204 assert!(content.contains("format: double"));
1205 assert!(content.contains("additionalProperties")); let _required_idx = content.find("required").unwrap();
1209 let _config_idx = content.find("config").unwrap();
1210 }
1214 _ => panic!("Expected Schema"),
1215 }
1216 }
1217
1218 #[test]
1219 fn test_visitor_bugs_v0_4_2() {
1220 let code_generic = r#"
1222 /// @openapi
1223 struct Container<T> {
1224 pub item: T,
1225 }
1226 "#;
1227 let item_struct: ItemStruct = syn::parse_str(code_generic).expect("Failed to parse struct");
1228 let mut visitor = OpenApiVisitor::default();
1229 visitor.visit_item_struct(&item_struct);
1230 match &visitor.items[0] {
1231 ExtractedItem::Schema { content, .. } => {
1232 assert!(
1234 content.contains("$ref: $T"),
1235 "Should use Smart Ref for generics (expected $ref: $T)"
1236 );
1237 }
1238 _ => panic!("Expected Schema"),
1239 }
1240
1241 let code_multiline = r#"
1243 /// @openapi
1244 struct User {
1245 /// @openapi
1246 /// example:
1247 /// - "Alice"
1248 /// - "Bob"
1249 pub names: Vec<String>
1250 }
1251 "#;
1252 let item_struct_m: ItemStruct =
1253 syn::parse_str(code_multiline).expect("Failed to parse struct");
1254 let mut visitor_m = OpenApiVisitor::default();
1255 visitor_m.visit_item_struct(&item_struct_m);
1256 match &visitor_m.items[0] {
1257 ExtractedItem::Schema { content, .. } => {
1258 assert!(content.contains("example:"), "Should contain example key");
1260 assert!(
1261 content.contains("- Alice"),
1262 "Should parse multi-line attributes (- Alice)"
1263 );
1264 }
1265 _ => panic!("Expected Schema"),
1266 }
1267
1268 let code_tags = r#"
1270 /// @openapi
1271 /// tags: [MyTag]
1272 mod my_mod {
1273 /// @openapi
1274 /// paths:
1275 /// /foo:
1276 /// get:
1277 /// description: op
1278 fn my_fn() {}
1279 }
1280 "#;
1281 let item_mod: ItemMod = syn::parse_str(code_tags).expect("Failed to parse mod");
1282 let mut visitor_t = OpenApiVisitor::default();
1283 visitor_t.visit_item_mod(&item_mod);
1284 match &visitor_t.items[1] {
1285 ExtractedItem::Schema { content, .. } => {
1287 let get_idx = content.find("get:").unwrap();
1289 let tags_idx = content.find("tags:").unwrap();
1290
1291 assert!(tags_idx > get_idx, "Tags should be inside/after get");
1293
1294 let desc_idx = content.find("description:").unwrap();
1296 assert!(
1297 tags_idx < desc_idx,
1298 "Tags should be injected before description (top of block)"
1299 );
1300 }
1301 _ => panic!("Expected Schema"),
1302 }
1303 }
1304
1305 #[test]
1306 fn test_visitor_pollution_v0_4_3() {
1307 let code = r#"
1308 /// @openapi
1309 struct Clean {
1310 /// Clean Description
1311 /// @openapi example: "dirty"
1312 pub field: String,
1313 }
1314 "#;
1315 let item_struct: ItemStruct = syn::parse_str(code).expect("Failed to parse struct");
1316 let mut visitor = OpenApiVisitor::default();
1317 visitor.visit_item_struct(&item_struct);
1318
1319 match &visitor.items[0] {
1320 ExtractedItem::Schema { content, .. } => {
1321 assert!(content.contains("description: Clean Description"));
1326 assert!(
1327 !content.contains("description: Clean Description @openapi"),
1328 "Should Clean Description"
1329 );
1330 assert!(
1331 content.contains("example: dirty"),
1332 "Should still have the example"
1333 );
1334 }
1335 _ => panic!("Expected Schema"),
1336 }
1337 }
1338
1339 #[test]
1340 fn test_type_alias_reflection() {
1341 let code = r#"
1342 /// @openapi
1343 /// format: uuid
1344 /// description: User ID Alias
1345 type UserId = String;
1346 "#;
1347 let item_type: ItemType = syn::parse_str(code).expect("Failed to parse type");
1348
1349 let mut visitor = OpenApiVisitor::default();
1350 visitor.visit_item_type(&item_type);
1351
1352 assert_eq!(visitor.items.len(), 1);
1353 match &visitor.items[0] {
1354 ExtractedItem::Schema { name, content, .. } => {
1355 assert_eq!(name.as_ref().unwrap(), "UserId");
1356 assert!(content.contains("type: string"));
1357 assert!(content.contains("format: uuid"));
1358 assert!(content.contains("description: User ID Alias"));
1359 }
1360 _ => panic!("Expected Schema"),
1361 }
1362 }
1363
1364 #[test]
1365 fn test_virtual_types_unit_struct() {
1366 let code = r#"
1367 /// @openapi
1368 /// type: string
1369 /// enum: [A, B]
1370 struct MyEnum;
1371 "#;
1372 let item_struct: ItemStruct = syn::parse_str(code).expect("Failed to parse struct");
1373 let mut visitor = OpenApiVisitor::default();
1374 visitor.visit_item_struct(&item_struct);
1375
1376 assert_eq!(visitor.items.len(), 1);
1378 match &visitor.items[0] {
1379 ExtractedItem::Schema { name, content, .. } => {
1380 assert_eq!(name.as_ref().unwrap(), "MyEnum");
1381 assert!(content.contains("type: string"));
1382 assert!(content.contains("enum:"));
1383 assert!(content.contains("A"));
1384 assert!(content.contains("B"));
1385 }
1386 _ => panic!("Expected Schema"),
1387 }
1388 }
1389
1390 #[test]
1391 fn test_global_virtual_type() {
1392 let code = r#"
1393 //! @openapi-type Email
1394 //! type: string
1395 //! format: email
1396 //! description: Valid email address
1397
1398 // Other code...
1399 fn main() {}
1400 "#;
1401 let file: File = syn::parse_str(code).expect("Failed to parse file");
1403
1404 let mut visitor = OpenApiVisitor::default();
1405 visitor.visit_file(&file);
1406
1407 let email_schema = visitor.items.iter().find(|i| {
1409 if let ExtractedItem::Schema { name, .. } = i {
1410 name.as_deref() == Some("Email")
1411 } else {
1412 false
1413 }
1414 });
1415
1416 assert!(email_schema.is_some(), "Should find Email schema");
1417 match email_schema.unwrap() {
1418 ExtractedItem::Schema { content, .. } => {
1419 assert!(content.contains("type: string"));
1420 assert!(content.contains("format: email"));
1421 }
1422 _ => panic!("Expected Schema"),
1423 }
1424 }
1425
1426 #[test]
1427 fn test_route_dsl_basic() {
1428 let code = r#"
1429 /// Get Users
1430 /// Returns a list of users.
1431 /// @route GET /users
1432 /// @tag Users
1433 fn get_users() {}
1434 "#;
1435 let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1436 let mut visitor = OpenApiVisitor::default();
1437 visitor.visit_item_fn(&item_fn);
1438
1439 assert_eq!(visitor.items.len(), 1);
1440 if let ExtractedItem::RouteDSL {
1441 content,
1442 operation_id,
1443 ..
1444 } = &visitor.items[0]
1445 {
1446 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1447 let yaml =
1448 crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL Parsing failed");
1449
1450 assert!(yaml.contains("paths:"));
1451 assert!(yaml.contains("/users:"));
1452 assert!(yaml.contains("get:"));
1453 assert!(yaml.contains("summary: Get Users"));
1454 assert!(yaml.contains("description:"));
1455 assert!(yaml.contains("Returns a list of users."));
1456 assert!(yaml.contains("tags:"));
1457 assert!(yaml.contains("- Users"));
1458 } else {
1459 panic!("Expected RouteDSL item, got {:?}", &visitor.items[0]);
1460 }
1461 }
1462
1463 #[test]
1464 fn test_route_dsl_params() {
1465 let code = r#"
1466 /// @route GET /users/{id}
1467 /// @path-param id: u32 "User ID"
1468 /// @query-param filter: Option<String> "Name filter"
1469 fn get_user() {}
1470 "#;
1471 let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1472 let mut visitor = OpenApiVisitor::default();
1473 visitor.visit_item_fn(&item_fn);
1474
1475 if let ExtractedItem::RouteDSL {
1476 content,
1477 operation_id,
1478 ..
1479 } = &visitor.items[0]
1480 {
1481 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1482 let yaml =
1483 crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1484
1485 assert!(yaml.contains("name: id"));
1487 assert!(yaml.contains("in: path"));
1488
1489 assert!(yaml.contains("required: true"));
1490 assert!(yaml.contains("format: int32"));
1491
1492 assert!(yaml.contains("name: filter"));
1494 assert!(yaml.contains("in: query"));
1495 assert!(yaml.contains("required: false")); } else {
1497 panic!("Expected RouteDSL item");
1498 }
1499 }
1500
1501 #[test]
1502 fn test_route_dsl_body_return() {
1503 let code = r#"
1504 /// @route POST /users
1505 /// @body String text/plain
1506 /// @return 201: u64 "Created ID"
1507 fn create_user() {}
1508 "#;
1509 let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1510 let mut visitor = OpenApiVisitor::default();
1511 visitor.visit_item_fn(&item_fn);
1512
1513 if let ExtractedItem::RouteDSL {
1514 content,
1515 operation_id,
1516 ..
1517 } = &visitor.items[0]
1518 {
1519 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1520 let yaml =
1521 crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1522
1523 assert!(yaml.contains("requestBody:"));
1525 assert!(yaml.contains("text/plain:")); assert!(yaml.contains("schema:"));
1527 assert!(yaml.contains("type: string"));
1528
1529 assert!(yaml.contains("responses:"));
1531 assert!(yaml.contains("'201':"));
1532 assert!(yaml.contains("description: Created ID"));
1533 assert!(yaml.contains("format: int64"));
1534 } else {
1535 panic!("Expected RouteDSL item");
1536 }
1537 }
1538
1539 #[test]
1540 fn test_route_dsl_security() {
1541 let code = r#"
1542 /// @route GET /secure
1543 /// @security oidcAuth("read")
1544 fn secure_op() {}
1545 "#;
1546 let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1547 let mut visitor = OpenApiVisitor::default();
1548 visitor.visit_item_fn(&item_fn);
1549
1550 if let ExtractedItem::RouteDSL {
1551 content,
1552 operation_id,
1553 ..
1554 } = &visitor.items[0]
1555 {
1556 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1557 let yaml =
1558 crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1559
1560 assert!(yaml.contains("security:"));
1561 assert!(yaml.contains("- oidcAuth:"));
1562 assert!(yaml.contains("- read"));
1563 } else {
1564 panic!("Expected RouteDSL item");
1565 }
1566 }
1567
1568 #[test]
1569 fn test_route_dsl_generics_and_unit() {
1570 let code = r#"
1571 /// @route POST /test
1572 /// @return 200: $Page<User> "Generic List"
1573 /// @return 204: () "Nothing"
1574 fn test_op() {}
1575 "#;
1576 let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1577 let mut visitor = OpenApiVisitor::default();
1578 visitor.visit_item_fn(&item_fn);
1579
1580 if let ExtractedItem::RouteDSL {
1581 content,
1582 operation_id,
1583 ..
1584 } = &visitor.items[0]
1585 {
1586 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1587 let yaml =
1588 crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1589
1590 assert!(yaml.contains("$ref: $Page<User>"));
1592 assert!(!yaml.contains("#/components/schemas/$Page<User>")); assert!(yaml.contains("'204':"));
1596 assert!(yaml.contains("description: Nothing"));
1597 let json: serde_json::Value = serde_yaml::from_str(&yaml).unwrap();
1600 let resp_204 = &json["paths"]["/test"]["post"]["responses"]["204"];
1601 assert!(
1602 resp_204.get("content").is_none(),
1603 "204 response should not have content"
1604 );
1605 } else {
1606 panic!("Expected RouteDSL item");
1607 }
1608 }
1609
1610 #[test]
1611 fn test_route_dsl_unit_return() {
1612 let code = r#"
1613 /// @route DELETE /delete
1614 /// @return 204: "Deleted Successfully"
1615 /// @return 202: () "Accepted"
1616 fn delete_op() {}
1617 "#;
1618 let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1619 let mut visitor = OpenApiVisitor::default();
1620 visitor.visit_item_fn(&item_fn);
1621
1622 if let ExtractedItem::RouteDSL {
1623 content,
1624 operation_id,
1625 ..
1626 } = &visitor.items[0]
1627 {
1628 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1629 let yaml =
1630 crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1631
1632 let json: serde_json::Value = serde_yaml::from_str(&yaml).unwrap();
1634 let responses = &json["paths"]["/delete"]["delete"]["responses"];
1635
1636 let resp_204 = &responses["204"];
1638 assert_eq!(resp_204["description"], "Deleted Successfully");
1639 assert!(
1640 resp_204.get("content").is_none(),
1641 "204 should have no content"
1642 );
1643
1644 let resp_202 = &responses["202"];
1646 assert_eq!(resp_202["description"], "Accepted");
1647 assert!(
1648 resp_202.get("content").is_none(),
1649 "202 should have no content"
1650 );
1651 } else {
1652 panic!("Expected RouteDSL item");
1653 }
1654 }
1655}
1656
1657#[cfg(test)]
1658mod dsl_tests {
1659 use super::*;
1660
1661 #[test]
1662 fn test_route_dsl_inline_params() {
1663 let code = r#"
1664 /// @route GET /items/{id: u32 "Item ID"}
1665 fn get_item() {}
1666 "#;
1667 let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1668 let mut visitor = OpenApiVisitor::default();
1669 visitor.visit_item_fn(&item_fn);
1670
1671 if let ExtractedItem::RouteDSL {
1672 content,
1673 operation_id,
1674 ..
1675 } = &visitor.items[0]
1676 {
1677 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1678 let yaml =
1679 crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1680
1681 assert!(yaml.contains("/items/{id}:"));
1683
1684 let json: serde_json::Value = serde_yaml::from_str(&yaml).unwrap();
1686 let params = &json["paths"]["/items/{id}"]["get"]["parameters"];
1687 assert!(params.is_array());
1688 assert_eq!(params.as_array().unwrap().len(), 1);
1689
1690 let p = ¶ms[0];
1691 assert_eq!(p["name"], "id");
1692 assert_eq!(p["in"], "path");
1693 assert_eq!(p["required"], true);
1694 assert_eq!(p["description"], "Item ID");
1695 assert_eq!(p["schema"]["type"], "integer");
1696 assert_eq!(p["schema"]["format"], "int32");
1697 } else {
1698 panic!("Expected RouteDSL item");
1699 }
1700 }
1701
1702 #[test]
1703 fn test_route_dsl_flexible_params() {
1704 let code = r#"
1705 /// @route GET /search
1706 /// @query-param q: String "Search Query"
1707 /// @query-param sort: deprecated required example="desc" "Sort Order"
1708 fn search() {}
1709 "#;
1710 let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1711 let mut visitor = OpenApiVisitor::default();
1712 visitor.visit_item_fn(&item_fn);
1713
1714 if let ExtractedItem::RouteDSL {
1715 content,
1716 operation_id,
1717 ..
1718 } = &visitor.items[0]
1719 {
1720 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1721 let yaml =
1722 crate::dsl::parse_route_dsl(&lines, operation_id).expect("DSL parsing failed");
1723
1724 let json: serde_json::Value = serde_yaml::from_str(&yaml).unwrap();
1725 let params = &json["paths"]["/search"]["get"]["parameters"];
1726 let params_arr = params.as_array().unwrap();
1727
1728 let q = params_arr.iter().find(|p| p["name"] == "q").unwrap();
1730 assert_eq!(q["description"], "Search Query");
1731
1732 let sort = params_arr.iter().find(|p| p["name"] == "sort").unwrap();
1734 assert_eq!(sort["deprecated"], true);
1735 assert_eq!(sort["required"], true);
1736 assert_eq!(sort["example"], "desc");
1737 assert_eq!(sort["description"], "Sort Order");
1738 } else {
1739 panic!("Expected RouteDSL item");
1740 }
1741 }
1742
1743 #[test]
1744 #[should_panic(expected = "Missing definition for path parameter 'id'")]
1745 fn test_route_dsl_validation_error() {
1746 let code = r#"
1747 /// @route GET /items/{id}
1748 fn get_item_fail() {}
1749 "#;
1750 let item_fn: ItemFn = syn::parse_str(code).expect("Failed to parse fn");
1751 let mut visitor = OpenApiVisitor::default();
1752 visitor.visit_item_fn(&item_fn);
1753
1754 if let ExtractedItem::RouteDSL {
1756 content,
1757 operation_id,
1758 ..
1759 } = &visitor.items[0]
1760 {
1761 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
1762 let _ = crate::dsl::parse_route_dsl(&lines, operation_id);
1763 }
1764 }
1765
1766 #[test]
1767 fn test_doc_comment_as_description() {
1768 let code = r#"
1769 /// This is a user struct.
1770 /// It has multiple lines.
1771 /// @openapi
1772 struct User { name: String }
1773 "#;
1774 let item: ItemStruct = syn::parse_str(code).unwrap();
1775 let mut v = OpenApiVisitor::default();
1776 v.visit_item_struct(&item);
1777
1778 match &v.items[0] {
1779 ExtractedItem::Schema { content, .. } => {
1780 assert!(
1781 content.contains("description: This is a user struct. It has multiple lines.")
1782 );
1783 }
1784 _ => panic!("Expected Schema"),
1785 }
1786 }
1787
1788 #[test]
1789 fn test_description_override() {
1790 let code = r#"
1791 /// Original Docs
1792 /// @openapi
1793 /// description: Overridden
1794 struct User { name: String }
1795 "#;
1796 let item: ItemStruct = syn::parse_str(code).unwrap();
1797 let mut v = OpenApiVisitor::default();
1798 v.visit_item_struct(&item);
1799
1800 match &v.items[0] {
1801 ExtractedItem::Schema { content, .. } => {
1802 assert!(content.contains("description: Overridden"));
1803 }
1805 _ => panic!("Expected Schema"),
1806 }
1807 }
1808
1809 #[test]
1810 fn test_implicit_safety() {
1811 let code = r#"
1812 /// Hidden internal struct
1813 struct Internal { secret: String }
1814 "#;
1815 let item: ItemStruct = syn::parse_str(code).unwrap();
1816 let mut v = OpenApiVisitor::default();
1817 v.visit_item_struct(&item);
1818 assert!(
1819 v.items.is_empty(),
1820 "Should not export struct without @openapi tag"
1821 );
1822 }
1823}