Skip to main content

camel_dsl/
openapi.rs

1//! OpenAPI 3.0.3 code-first generation from `rest:` DSL AST blocks.
2//!
3//! Spec §10: each operation maps to `paths.<full_path>.<verb>` with
4//! parameters from path templates, requestBody from request_schema, and
5//! responses from response metadata or weak stubs.
6
7use 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
16/// OpenAPI 3.0.3 HTTP verbs — anything else is logged as a warning.
17/// Kept in sync with `KNOWN_VERBS` in rest.rs (the lowering authority); verbs
18/// not in that set are rejected before reaching OpenAPI generation, so they
19/// must not appear here either.
20const VALID_OPENAPI_VERBS: &[&str] = &["get", "post", "put", "delete", "patch", "head", "options"];
21
22/// Result of OpenAPI generation — the document plus any warnings.
23#[derive(Debug, Clone)]
24pub struct OpenApiGenerationResult {
25    /// The OpenAPI 3.0.3 document as `serde_json::Value`.
26    pub document: Value,
27    /// Warnings (e.g. operations with missing schemas using weak stubs).
28    pub warnings: Vec<String>,
29}
30
31/// Generate an OpenAPI 3.0.3 document from parsed `rest:` AST blocks.
32///
33/// Each operation maps to a `paths.<full_path>.<verb>` entry. Missing
34/// schemas produce weak stubs (`type: object`) and a warning.
35pub 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            // I3: warn on unknown HTTP verbs (don't reject — weak-stub philosophy)
48            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            // I1: detect duplicate (path, verb) — last wins, warn
58            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            // M2: detect duplicate operationId (only for explicitly-set ids; auto-generated
67            // operationIds are derived from verb+path and inherit the I1 last-wins dedup)
68            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    // Servers: collect unique (host, port) from all rest blocks
81    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
101/// Build a single OpenAPI operation object.
102fn 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    // I2: path-template params (inferred as string), with declared overrides
112    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    // I2: declared params not in path template → additional query params
130    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    // M1: trim leading slash for cleaner default operationId
142    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    // --- Responses ---
162    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        // Warn if author also supplied a response schema — it will be ignored
175        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    // --- Request body (body verbs only) ---
215    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
249/// M3: insert declared response headers into a response object map when present.
250/// Shared between the 204 and non-204 response branches to keep header handling DRY.
251fn 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    // --- Schema tests (will pass after full implementation above) ---
356
357    #[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        // Should have 2 warnings: no request_schema + no response schema
368        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        // No request-schema warning for this op
411        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        // No response-schema warning for this op
434        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        // I1: two blocks defining GET /api/users → warning
473        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        // I2: op.parameters["id"] overrides inferred string type
489        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        // I2: non-path param in op.parameters → query param
505        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(); // allow-unwrap
513        let query_param = params
514            .iter()
515            .find(|p| p["name"] == "limit")
516            .expect("should have limit param"); // allow-unwrap
517        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        // M1: /api/users → get_api_users (not get__api_users)
524        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(); // allow-unwrap
531        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        // I1: two rest blocks with identical (host, port) → one server entry
562        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(); // allow-unwrap
566        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        // I1: two rest blocks with different (host, port) → two server entries
573        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(); // allow-unwrap
577        assert_eq!(servers.len(), 2);
578        let urls: Vec<&str> = servers
579            .iter()
580            .map(|s| s["url"].as_str().unwrap()) // allow-unwrap
581            .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        // I2: response.headers populated → ["headers"] present on 200 response
589        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        // I2: response.headers populated on 204 path → still emitted (no content key)
611        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        // I3: unknown verb → warning, but operation still emitted (weak-stub philosophy)
633        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        // Operation is still emitted
644        assert!(result.document["paths"]["/api/foo"].get("xyz").is_some());
645    }
646
647    #[test]
648    fn duplicate_operation_id_warns() {
649        // M2: same explicit operationId across distinct operations → warning
650        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    // ── Within-block detection (rc-y1xa Task 2) ──
664
665    #[test]
666    fn openapi_two_gets_distinct_paths_produce_distinct_path_entries() {
667        // Provide response schemas to avoid weak-stub warnings.
668        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}