1use serde_json::{Map, Value, json};
8
9use crate::rest::{
10 build_full_path, default_status_for_verb, extract_param_names, parse_path_template,
11 verb_has_body,
12};
13use crate::route_ast::{RouteDslRest, RouteDslRestOperation};
14use std::collections::HashSet;
15
16const VALID_OPENAPI_VERBS: &[&str] = &["get", "post", "put", "delete", "patch", "head", "options"];
21
22#[derive(Debug, Clone)]
24pub struct OpenApiGenerationResult {
25 pub document: Value,
27 pub warnings: Vec<String>,
29}
30
31pub fn generate_openapi(
36 rest_blocks: &[RouteDslRest],
37 title: &str,
38 version: &str,
39) -> OpenApiGenerationResult {
40 let mut warnings = Vec::new();
41 let mut paths: Map<String, Value> = Map::new();
42 let mut seen_operation_ids: HashSet<String> = HashSet::new();
43
44 for rest in rest_blocks {
45 for op in &rest.operations {
46 let verb_lower = op.method.to_lowercase();
47 if !VALID_OPENAPI_VERBS.contains(&verb_lower.as_str()) {
49 warnings.push(format!(
50 "unknown HTTP verb '{}' — may produce invalid OpenAPI",
51 op.method
52 ));
53 }
54 let full_path = build_full_path(&rest.path, &op.path);
55 let path_entry = paths.entry(full_path.clone()).or_insert_with(|| json!({}));
56 let op_label = op.operation_id.as_deref().unwrap_or(&verb_lower);
57 if path_entry.get(&verb_lower).is_some() {
59 warnings.push(format!(
60 "duplicate operation: {} {} ({}) — overwrites previous definition",
61 verb_lower.to_uppercase(),
62 full_path,
63 op_label
64 ));
65 }
66 if let Some(id) = op.operation_id.as_deref()
69 && !seen_operation_ids.insert(id.to_string())
70 {
71 warnings.push(format!(
72 "duplicate operationId '{id}' — operationIds must be unique within a document"
73 ));
74 }
75 let operation_obj = build_operation(&verb_lower, op, &full_path, &mut warnings);
76 path_entry[&verb_lower] = operation_obj;
77 }
78 }
79
80 let mut seen = std::collections::HashSet::new();
82 let servers: Vec<Value> = rest_blocks
83 .iter()
84 .filter(|r| seen.insert((r.host.as_str(), r.port)))
85 .map(|r| json!({ "url": format!("http://{}:{}", r.host, r.port) }))
86 .collect();
87
88 let document = json!({
89 "openapi": "3.0.3",
90 "info": {
91 "title": title,
92 "version": version,
93 },
94 "servers": servers,
95 "paths": Value::Object(paths),
96 });
97
98 OpenApiGenerationResult { document, warnings }
99}
100
101fn build_operation(
103 verb: &str,
104 op: &RouteDslRestOperation,
105 full_path: &str,
106 warnings: &mut Vec<String>,
107) -> Value {
108 let segments = parse_path_template(full_path);
109 let param_names = extract_param_names(&segments);
110
111 let mut parameters: Vec<Value> = param_names
113 .iter()
114 .map(|name| {
115 let schema = op
116 .parameters
117 .get(name)
118 .cloned()
119 .unwrap_or_else(|| json!({ "type": "string" }));
120 json!({
121 "name": name,
122 "in": "path",
123 "required": true,
124 "schema": schema
125 })
126 })
127 .collect();
128
129 for (name, schema) in &op.parameters {
131 if !param_names.contains(name) {
132 parameters.push(json!({
133 "name": name,
134 "in": "query",
135 "required": false,
136 "schema": schema
137 }));
138 }
139 }
140
141 let operation_id = op.operation_id.clone().unwrap_or_else(|| {
143 format!(
144 "{}_{}",
145 verb,
146 full_path.trim_start_matches('/').replace('/', "_")
147 )
148 });
149
150 let mut operation = Map::new();
151 operation.insert("operationId".to_string(), json!(operation_id));
152
153 if let Some(desc) = &op.description {
154 operation.insert("description".to_string(), json!(desc));
155 }
156
157 if !parameters.is_empty() {
158 operation.insert("parameters".to_string(), json!(parameters));
159 }
160
161 let success_code = op
163 .success_status
164 .unwrap_or_else(|| default_status_for_verb(verb));
165 let op_label = op.operation_id.as_deref().unwrap_or(verb);
166 let response_desc = op
167 .response
168 .as_ref()
169 .and_then(|r| r.description.clone())
170 .unwrap_or_else(|| default_response_description(success_code).to_string());
171
172 let mut responses = Map::new();
173 if success_code == 204 {
174 if op
176 .response
177 .as_ref()
178 .and_then(|r| r.schema.as_ref())
179 .is_some()
180 {
181 warnings.push(format!(
182 "operation '{op_label}' — success_status 204 ignores response.schema (No Content)"
183 ));
184 }
185 let mut resp204 = Map::new();
186 resp204.insert("description".to_string(), json!(response_desc));
187 insert_response_headers(&mut resp204, op);
188 responses.insert("204".to_string(), Value::Object(resp204));
189 } else {
190 let response_schema = op
191 .response
192 .as_ref()
193 .and_then(|r| r.schema.clone())
194 .unwrap_or_else(|| {
195 let label = op.operation_id.as_deref().unwrap_or(verb);
196 warnings.push(format!(
197 "operation '{label}' ({verb} {full_path}) — no response schema, using weak stub (type: object)"
198 ));
199 json!({ "type": "object" })
200 });
201 let mut resp_obj = Map::new();
202 resp_obj.insert("description".to_string(), json!(response_desc));
203 resp_obj.insert(
204 "content".to_string(),
205 json!({
206 op.produces.as_str(): { "schema": response_schema }
207 }),
208 );
209 insert_response_headers(&mut resp_obj, op);
210 responses.insert(success_code.to_string(), Value::Object(resp_obj));
211 }
212 operation.insert("responses".to_string(), json!(responses));
213
214 if verb_has_body(verb) {
216 let schema = op.request_schema.clone().unwrap_or_else(|| {
217 let label = op.operation_id.as_deref().unwrap_or(verb);
218 warnings.push(format!(
219 "operation '{label}' ({verb} {full_path}) — body verb has no request_schema, using weak stub (type: object)"
220 ));
221 json!({ "type": "object" })
222 });
223 operation.insert(
224 "requestBody".to_string(),
225 json!({
226 "content": {
227 op.consumes.as_str(): { "schema": schema }
228 }
229 }),
230 );
231 }
232
233 Value::Object(operation)
234}
235
236fn default_response_description(code: u16) -> &'static str {
237 match code {
238 200 => "OK",
239 201 => "Created",
240 202 => "Accepted",
241 204 => "No Content",
242 400 => "Bad Request",
243 404 => "Not Found",
244 500 => "Internal Server Error",
245 _ => "Response",
246 }
247}
248
249fn insert_response_headers(resp: &mut Map<String, Value>, op: &RouteDslRestOperation) {
252 if let Some(r) = &op.response
253 && !r.headers.is_empty()
254 {
255 resp.insert("headers".to_string(), json!(r.headers));
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use crate::route_ast::RouteDslRest;
263 use std::collections::BTreeMap;
264
265 fn make_rest(path: &str, ops: &[(&str, RouteDslRestOperation)]) -> RouteDslRest {
266 let operations = ops
267 .iter()
268 .map(|(verb, op)| {
269 let mut op = op.clone();
270 op.method = verb.to_string();
271 op
272 })
273 .collect();
274 RouteDslRest {
275 host: "0.0.0.0".to_string(),
276 port: 8080,
277 path: path.to_string(),
278 operations,
279 }
280 }
281
282 fn make_op(operation_id: &str) -> RouteDslRestOperation {
283 RouteDslRestOperation {
284 method: "GET".to_string(),
285 path: "/".to_string(),
286 operation_id: Some(operation_id.to_string()),
287 to: Some(format!("direct:{operation_id}")),
288 steps: vec![],
289 consumes: "application/json".to_string(),
290 produces: "application/json".to_string(),
291 success_status: None,
292 request_schema: None,
293 response: None,
294 description: None,
295 parameters: BTreeMap::new(),
296 }
297 }
298
299 #[test]
300 fn generates_valid_openapi_skeleton() {
301 let result = generate_openapi(&[], "Test API", "2.0.0");
302 assert_eq!(result.document["openapi"], "3.0.3");
303 assert_eq!(result.document["info"]["title"], "Test API");
304 assert_eq!(result.document["info"]["version"], "2.0.0");
305 assert!(result.document["paths"].as_object().unwrap().is_empty());
306 assert!(result.warnings.is_empty());
307 }
308
309 #[test]
310 fn maps_get_operation_to_paths() {
311 let rest = make_rest("/api/users", &[("get", make_op("listUsers"))]);
312 let result = generate_openapi(&[rest], "API", "1.0.0");
313 let paths = &result.document["paths"];
314 assert_eq!(paths["/api/users"]["get"]["operationId"], "listUsers");
315 }
316
317 #[test]
318 fn merges_verbs_on_same_path() {
319 let rest = make_rest(
320 "/api/users",
321 &[
322 ("get", make_op("listUsers")),
323 ("post", make_op("createUser")),
324 ],
325 );
326 let result = generate_openapi(&[rest], "API", "1.0.0");
327 let path_entry = &result.document["paths"]["/api/users"];
328 assert!(path_entry.get("get").is_some());
329 assert!(path_entry.get("post").is_some());
330 }
331
332 #[test]
333 fn extracts_path_params_from_template() {
334 let mut op = make_op("getUser");
335 op.path = "/{id}".to_string();
336 let rest = make_rest("/api/users", &[("get", op)]);
337 let result = generate_openapi(&[rest], "API", "1.0.0");
338 let params = &result.document["paths"]["/api/users/{id}"]["get"]["parameters"];
339 assert_eq!(params[0]["name"], "id");
340 assert_eq!(params[0]["in"], "path");
341 assert_eq!(params[0]["required"], true);
342 assert_eq!(params[0]["schema"]["type"], "string");
343 }
344
345 #[test]
346 fn default_operation_id_when_missing() {
347 let mut op = make_op("x");
348 op.operation_id = None;
349 let rest = make_rest("/api/items", &[("get", op)]);
350 let result = generate_openapi(&[rest], "API", "1.0.0");
351 let op_id = &result.document["paths"]["/api/items"]["get"]["operationId"];
352 assert!(op_id.as_str().unwrap().contains("get"));
353 }
354
355 #[test]
358 fn body_verb_gets_request_body_with_weak_stub_and_warning() {
359 let rest = make_rest("/api/users", &[("post", make_op("createUser"))]);
360 let result = generate_openapi(&[rest], "API", "1.0.0");
361 let post = &result.document["paths"]["/api/users"]["post"];
362 assert!(post.get("requestBody").is_some());
363 assert_eq!(
364 post["requestBody"]["content"]["application/json"]["schema"]["type"],
365 "object"
366 );
367 assert!(result.warnings.len() >= 2);
369 }
370
371 #[test]
372 fn get_with_no_schema_uses_weak_stub_and_warns() {
373 let rest = make_rest("/api/users", &[("get", make_op("listUsers"))]);
374 let result = generate_openapi(&[rest], "API", "1.0.0");
375 let resp = &result.document["paths"]["/api/users"]["get"]["responses"]["200"];
376 assert_eq!(
377 resp["content"]["application/json"]["schema"]["type"],
378 "object"
379 );
380 assert!(result.warnings.iter().any(|w| w.contains("listUsers")));
381 }
382
383 #[test]
384 fn delete_204_has_no_content() {
385 let mut op = make_op("deleteUser");
386 op.success_status = Some(204);
387 op.path = "/{id}".to_string();
388 let rest = make_rest("/api/users", &[("delete", op)]);
389 let result = generate_openapi(&[rest], "API", "1.0.0");
390 let resp = &result.document["paths"]["/api/users/{id}"]["delete"]["responses"]["204"];
391 assert!(resp.get("content").is_none());
392 assert_eq!(resp["description"], "No Content");
393 }
394
395 #[test]
396 fn provided_request_schema_used_in_body() {
397 let mut op = make_op("createUser");
398 op.request_schema = Some(json!({
399 "type": "object",
400 "properties": {
401 "name": { "type": "string" }
402 },
403 "required": ["name"]
404 }));
405 let rest = make_rest("/api/users", &[("post", op)]);
406 let result = generate_openapi(&[rest], "API", "1.0.0");
407 let schema = &result.document["paths"]["/api/users"]["post"]["requestBody"]["content"]["application/json"]
408 ["schema"];
409 assert_eq!(schema["properties"]["name"]["type"], "string");
410 assert!(!result.warnings.iter().any(|w| w.contains("request_schema")));
412 }
413
414 #[test]
415 fn provided_response_schema_used() {
416 let mut op = make_op("listUsers");
417 op.response = Some(crate::route_ast::RouteDslRestResponse {
418 description: Some("A list of users".to_string()),
419 schema: Some(json!({
420 "type": "array",
421 "items": { "type": "object" }
422 })),
423 headers: BTreeMap::new(),
424 });
425 let rest = make_rest("/api/users", &[("get", op)]);
426 let result = generate_openapi(&[rest], "API", "1.0.0");
427 let resp = &result.document["paths"]["/api/users"]["get"]["responses"]["200"];
428 assert_eq!(resp["description"], "A list of users");
429 assert_eq!(
430 resp["content"]["application/json"]["schema"]["type"],
431 "array"
432 );
433 assert!(
435 !result
436 .warnings
437 .iter()
438 .any(|w| w.contains("response schema"))
439 );
440 }
441
442 #[test]
443 fn post_defaults_to_201() {
444 let mut op = make_op("createUser");
445 op.request_schema = Some(json!({ "type": "object" }));
446 op.response = Some(crate::route_ast::RouteDslRestResponse {
447 description: None,
448 schema: Some(json!({ "type": "object" })),
449 headers: BTreeMap::new(),
450 });
451 let rest = make_rest("/api/users", &[("post", op)]);
452 let result = generate_openapi(&[rest], "API", "1.0.0");
453 let responses = &result.document["paths"]["/api/users"]["post"]["responses"];
454 assert!(responses.get("201").is_some());
455 assert!(responses.get("200").is_none());
456 }
457
458 #[test]
459 fn description_included_when_provided() {
460 let mut op = make_op("listUsers");
461 op.description = Some("Returns all users".to_string());
462 let rest = make_rest("/api/users", &[("get", op)]);
463 let result = generate_openapi(&[rest], "API", "1.0.0");
464 assert_eq!(
465 result.document["paths"]["/api/users"]["get"]["description"],
466 "Returns all users"
467 );
468 }
469
470 #[test]
471 fn duplicate_path_verb_warns() {
472 let rest1 = make_rest("/api/users", &[("get", make_op("listUsers"))]);
474 let rest2 = make_rest("/api/users", &[("get", make_op("listUsersAlt"))]);
475 let result = generate_openapi(&[rest1, rest2], "API", "1.0.0");
476 assert!(
477 result
478 .warnings
479 .iter()
480 .any(|w| w.contains("duplicate operation")),
481 "expected duplicate warning, got: {:?}",
482 result.warnings
483 );
484 }
485
486 #[test]
487 fn declared_param_overrides_inferred_type() {
488 let mut op = make_op("getUser");
490 op.path = "/{id}".to_string();
491 op.parameters.insert(
492 "id".to_string(),
493 json!({ "type": "integer", "format": "int64" }),
494 );
495 let rest = make_rest("/api/users", &[("get", op)]);
496 let result = generate_openapi(&[rest], "API", "1.0.0");
497 let params = &result.document["paths"]["/api/users/{id}"]["get"]["parameters"];
498 assert_eq!(params[0]["schema"]["type"], "integer");
499 assert_eq!(params[0]["schema"]["format"], "int64");
500 }
501
502 #[test]
503 fn declared_additional_param_becomes_query() {
504 let mut op = make_op("listUsers");
506 op.parameters
507 .insert("limit".to_string(), json!({ "type": "integer" }));
508 let rest = make_rest("/api/users", &[("get", op)]);
509 let result = generate_openapi(&[rest], "API", "1.0.0");
510 let params = result.document["paths"]["/api/users"]["get"]["parameters"]
511 .as_array()
512 .unwrap(); let query_param = params
514 .iter()
515 .find(|p| p["name"] == "limit")
516 .expect("should have limit param"); assert_eq!(query_param["in"], "query");
518 assert_eq!(query_param["schema"]["type"], "integer");
519 }
520
521 #[test]
522 fn default_operation_id_trims_leading_slash() {
523 let mut op = make_op("x");
525 op.operation_id = None;
526 let rest = make_rest("/api/users", &[("get", op)]);
527 let result = generate_openapi(&[rest], "API", "1.0.0");
528 let op_id = result.document["paths"]["/api/users"]["get"]["operationId"]
529 .as_str()
530 .unwrap(); assert!(
532 !op_id.starts_with("_"),
533 "should not start with underscore: {op_id}"
534 );
535 }
536
537 fn make_rest_with_host(
538 host: &str,
539 port: u16,
540 path: &str,
541 ops: &[(&str, RouteDslRestOperation)],
542 ) -> RouteDslRest {
543 let operations = ops
544 .iter()
545 .map(|(verb, op)| {
546 let mut op = op.clone();
547 op.method = verb.to_string();
548 op
549 })
550 .collect();
551 RouteDslRest {
552 host: host.to_string(),
553 port,
554 path: path.to_string(),
555 operations,
556 }
557 }
558
559 #[test]
560 fn servers_dedup_same_host_port() {
561 let rest1 = make_rest_with_host("0.0.0.0", 8080, "/api/a", &[("get", make_op("a"))]);
563 let rest2 = make_rest_with_host("0.0.0.0", 8080, "/api/b", &[("get", make_op("b"))]);
564 let result = generate_openapi(&[rest1, rest2], "API", "1.0.0");
565 let servers = result.document["servers"].as_array().unwrap(); assert_eq!(servers.len(), 1, "expected dedup to single server");
567 assert_eq!(servers[0]["url"], "http://0.0.0.0:8080");
568 }
569
570 #[test]
571 fn servers_distinct_when_host_or_port_differs() {
572 let rest1 = make_rest_with_host("0.0.0.0", 8080, "/api/a", &[("get", make_op("a"))]);
574 let rest2 = make_rest_with_host("127.0.0.1", 9090, "/api/b", &[("get", make_op("b"))]);
575 let result = generate_openapi(&[rest1, rest2], "API", "1.0.0");
576 let servers = result.document["servers"].as_array().unwrap(); assert_eq!(servers.len(), 2);
578 let urls: Vec<&str> = servers
579 .iter()
580 .map(|s| s["url"].as_str().unwrap()) .collect();
582 assert!(urls.contains(&"http://0.0.0.0:8080"));
583 assert!(urls.contains(&"http://127.0.0.1:9090"));
584 }
585
586 #[test]
587 fn response_headers_included_for_200() {
588 let mut op = make_op("listUsers");
590 op.response = Some(crate::route_ast::RouteDslRestResponse {
591 description: None,
592 schema: Some(json!({ "type": "array" })),
593 headers: BTreeMap::from([(
594 "X-Total-Count".to_string(),
595 json!({ "schema": { "type": "integer" } }),
596 )]),
597 });
598 let rest = make_rest("/api/users", &[("get", op)]);
599 let result = generate_openapi(&[rest], "API", "1.0.0");
600 let resp = &result.document["paths"]["/api/users"]["get"]["responses"]["200"];
601 assert!(resp.get("headers").is_some(), "expected headers key");
602 assert_eq!(
603 resp["headers"]["X-Total-Count"]["schema"]["type"],
604 "integer"
605 );
606 }
607
608 #[test]
609 fn response_headers_included_for_204() {
610 let mut op = make_op("deleteUser");
612 op.path = "/{id}".to_string();
613 op.success_status = Some(204);
614 op.response = Some(crate::route_ast::RouteDslRestResponse {
615 description: None,
616 schema: None,
617 headers: BTreeMap::from([(
618 "X-Request-Id".to_string(),
619 json!({ "schema": { "type": "string" } }),
620 )]),
621 });
622 let rest = make_rest("/api/users", &[("delete", op)]);
623 let result = generate_openapi(&[rest], "API", "1.0.0");
624 let resp = &result.document["paths"]["/api/users/{id}"]["delete"]["responses"]["204"];
625 assert!(resp.get("headers").is_some(), "expected headers key on 204");
626 assert_eq!(resp["headers"]["X-Request-Id"]["schema"]["type"], "string");
627 assert!(resp.get("content").is_none(), "204 must not have content");
628 }
629
630 #[test]
631 fn unknown_verb_warns_but_does_not_reject() {
632 let rest = make_rest("/api/foo", &[("xyz", make_op("fooOp"))]);
634 let result = generate_openapi(&[rest], "API", "1.0.0");
635 assert!(
636 result
637 .warnings
638 .iter()
639 .any(|w| w.contains("unknown HTTP verb 'xyz'")),
640 "expected unknown-verb warning, got: {:?}",
641 result.warnings
642 );
643 assert!(result.document["paths"]["/api/foo"].get("xyz").is_some());
645 }
646
647 #[test]
648 fn duplicate_operation_id_warns() {
649 let rest1 = make_rest("/api/a", &[("get", make_op("sameId"))]);
651 let rest2 = make_rest("/api/b", &[("get", make_op("sameId"))]);
652 let result = generate_openapi(&[rest1, rest2], "API", "1.0.0");
653 assert!(
654 result
655 .warnings
656 .iter()
657 .any(|w| w.contains("duplicate operationId 'sameId'")),
658 "expected duplicate operationId warning, got: {:?}",
659 result.warnings
660 );
661 }
662
663 #[test]
666 fn openapi_two_gets_distinct_paths_produce_distinct_path_entries() {
667 let op_a = RouteDslRestOperation {
669 path: "/health".to_string(),
670 response: Some(crate::route_ast::RouteDslRestResponse {
671 description: None,
672 schema: Some(json!({"type": "object"})),
673 headers: BTreeMap::new(),
674 }),
675 ..make_op("health")
676 };
677 let op_b = RouteDslRestOperation {
678 path: "/conteos".to_string(),
679 response: Some(crate::route_ast::RouteDslRestResponse {
680 description: None,
681 schema: Some(json!({"type": "object"})),
682 headers: BTreeMap::new(),
683 }),
684 ..make_op("conteos")
685 };
686 let rest = make_rest("/api", &[("GET", op_a), ("GET", op_b)]);
687 let result = generate_openapi(&[rest], "t", "1.0.0");
688 let paths = result.document["paths"].as_object().unwrap();
689 assert!(paths.contains_key("/api/health"));
690 assert!(paths.contains_key("/api/conteos"));
691 assert!(
692 result.warnings.is_empty(),
693 "unexpected warnings: {:?}",
694 result.warnings
695 );
696 }
697
698 #[test]
699 fn openapi_within_block_duplicate_path_verb_warns_last_wins() {
700 let rest = make_rest(
701 "/api",
702 &[
703 (
704 "GET",
705 RouteDslRestOperation {
706 path: "/x".to_string(),
707 ..make_op("first")
708 },
709 ),
710 (
711 "GET",
712 RouteDslRestOperation {
713 path: "/x".to_string(),
714 ..make_op("second")
715 },
716 ),
717 ],
718 );
719 let result = generate_openapi(&[rest], "t", "1.0.0");
720 assert!(
721 result
722 .warnings
723 .iter()
724 .any(|w| w.contains("duplicate operation")),
725 "expected duplicate-operation warning, got: {:?}",
726 result.warnings
727 );
728 }
729}