Skip to main content

forge_parser/
lib.rs

1//! OpenAPI 3.0 JSON → `forge_ir::Ir`.
2//!
3//! Stage 3 supports a deliberately narrow subset of OpenAPI 3.0.x. See
4//! `docs/parser-coverage.md` for the precise list. Anything outside the
5//! subset produces an `error`-severity diagnostic with a JSON-pointer
6//! location — never silent best-effort output.
7//!
8//! # Public surface
9//!
10//! ```no_run
11//! let json = std::fs::read_to_string("openapi.json").unwrap();
12//! match forge_parser::parse_str(&json) {
13//!     Ok(out) => {
14//!         for d in &out.diagnostics { eprintln!("{}: {}", d.code, d.message); }
15//!         if let Some(ir) = out.spec { println!("{} operations", ir.operations.len()); }
16//!     }
17//!     Err(e) => eprintln!("fatal: {e}"),
18//! }
19//! ```
20
21#![forbid(unsafe_code)]
22
23mod ctx;
24mod diag;
25pub mod external;
26mod finalize;
27mod normalize;
28mod operations;
29mod pointer;
30mod ref_walk;
31mod refs;
32mod sanitize;
33mod schema;
34mod security;
35mod value;
36
37pub use external::{FileResolver, NoExternalResolver, Resolver, ResolverError};
38
39use forge_ir::{
40    ApiInfo, Callback, Contact, Diagnostic, Example, ExternalDocs, Ir, Link, Server,
41    ServerVariable, SpecLocation, Tag, XmlObject,
42};
43use serde_json::Value as J;
44use thiserror::Error;
45
46use crate::ctx::Ctx;
47use crate::pointer::Ptr;
48use crate::schema::{parse_schema, NameHint};
49
50#[derive(Debug, Error)]
51pub enum ParseError {
52    #[error("invalid JSON: {0}")]
53    InvalidJson(String),
54    #[error("input is empty")]
55    Empty,
56    #[error("root document must be a JSON object")]
57    NotObject,
58    #[error("could not read input file `{path}`: {message}")]
59    Io { path: String, message: String },
60}
61
62#[derive(Debug, Default)]
63pub struct ParseOutput {
64    pub spec: Option<Ir>,
65    pub diagnostics: Vec<Diagnostic>,
66}
67
68/// Parse an OpenAPI 3.0 JSON document.
69pub fn parse_str(source: &str) -> Result<ParseOutput, ParseError> {
70    parse_str_with_file(source, None)
71}
72
73/// Parse with a `file` label that gets attached to every `SpecLocation`.
74pub fn parse_str_with_file(source: &str, file: Option<&str>) -> Result<ParseOutput, ParseError> {
75    parse_with_resolver(
76        source,
77        file,
78        Box::new(external::NoExternalResolver),
79        ctx::synthetic_main_path(),
80    )
81}
82
83/// Parse a spec from a filesystem path. External `$ref`s are resolved
84/// relative to the spec file's parent directory; paths that escape that
85/// root are rejected with `parser/E-EXTERNAL-REF`.
86pub fn parse_path(path: &std::path::Path) -> Result<ParseOutput, ParseError> {
87    let canonical = path.canonicalize().map_err(|e| ParseError::Io {
88        path: path.display().to_string(),
89        message: e.to_string(),
90    })?;
91    let source = std::fs::read_to_string(&canonical).map_err(|e| ParseError::Io {
92        path: canonical.display().to_string(),
93        message: e.to_string(),
94    })?;
95    let resolver = external::FileResolver::new(&canonical).map_err(|e| ParseError::Io {
96        path: canonical.display().to_string(),
97        message: e.to_string(),
98    })?;
99    // The label only goes into `SpecLocation.file`. Use the filename so
100    // generated diagnostics stay portable across machines (callers who
101    // need the absolute path can join it with a known root themselves).
102    let label = canonical
103        .file_name()
104        .and_then(|s| s.to_str())
105        .unwrap_or("")
106        .to_string();
107    parse_with_resolver(&source, Some(&label), Box::new(resolver), canonical)
108}
109
110fn parse_with_resolver(
111    source: &str,
112    file: Option<&str>,
113    resolver: Box<dyn external::Resolver>,
114    main_doc: std::path::PathBuf,
115) -> Result<ParseOutput, ParseError> {
116    if source.trim().is_empty() {
117        return Err(ParseError::Empty);
118    }
119    let root: J =
120        serde_json::from_str(source).map_err(|e| ParseError::InvalidJson(e.to_string()))?;
121    let root_map = match &root {
122        J::Object(m) => m,
123        _ => return Err(ParseError::NotObject),
124    };
125
126    let mut ctx = Ctx::with_resolver(file, resolver, main_doc);
127    // Cache the main spec's root so structural refs (`#/components/parameters/Page`)
128    // can resolve without going back through the resolver.
129    ctx.doc_roots.insert(ctx.current_doc.clone(), root.clone());
130    let mut ptr = Ptr::new();
131
132    // 1. Version check.
133    if !check_version(&mut ctx, root_map, &mut ptr) {
134        // Bail early but still emit ParseOutput with diagnostic.
135        return Ok(ParseOutput {
136            spec: None,
137            diagnostics: ctx.diagnostics,
138        });
139    }
140
141    // 2. Info / servers (best-effort; missing fields produce diagnostics).
142    parse_info(&mut ctx, root_map, &mut ptr);
143    parse_servers(&mut ctx, root_map, &mut ptr);
144    let tags = parse_tags(&mut ctx, root_map, &mut ptr);
145
146    // 3. Security schemes (parses what it can; oauth2 / openIdConnect emit
147    //    deferred-feature diagnostics).
148    security::walk_components(&mut ctx, root_map, &mut ptr);
149
150    // 4. Components: pre-register schemas for forward $ref resolution, then
151    //    walk each in dependency-aware order so allOf $refs resolve to
152    //    already-walked targets.
153    register_component_schemas(&mut ctx, root_map);
154    walk_component_schemas(&mut ctx, root_map, &mut ptr);
155
156    // 5. Top-level `security` (default for operations that don't override).
157    if let Some(top_sec) = root_map.get("security") {
158        ptr.with_token("security", |ptr| {
159            ctx.default_security = security::parse_requirements(&mut ctx, top_sec, ptr);
160        });
161    }
162
163    // 6. Paths.
164    if let Some(paths) = root_map.get("paths") {
165        ptr.with_token("paths", |ptr| {
166            operations::parse_paths(&mut ctx, paths, ptr);
167        });
168    }
169
170    // 6b. 3.1+ webhooks (top-level inbound operations). Generators that
171    //     handle webhooks read `Ir.webhooks`; client generators ignore.
172    if let Some(webhooks) = root_map.get("webhooks") {
173        ptr.with_token("webhooks", |ptr| {
174            operations::parse_webhooks(&mut ctx, webhooks, ptr);
175        });
176    }
177
178    // 7. Root-level `externalDocs`. Reads here rather than in
179    //    `parse_info` because OAS puts it on the root, not nested under
180    //    `info`.
181    let root_external_docs = parse_external_docs(&mut ctx, root_map.get("externalDocs"), &mut ptr);
182
183    // 7b. Surface unused `components.pathItems` declarations. They
184    //     don't affect the IR (operations land via $ref from paths /
185    //     webhooks / callbacks); a declared-and-never-used entry is
186    //     almost certainly a spec bug.
187    scan_unused_component_path_items(&mut ctx, root_map, &mut ptr);
188    scan_unused_component_media_types(&mut ctx, root_map, &mut ptr);
189
190    // 7c. Root-level annotations. Carried verbatim — generators that
191    //     care can read them; most ignore them.
192    let json_schema_dialect = root_map
193        .get("jsonSchemaDialect")
194        .and_then(J::as_str)
195        .map(String::from);
196    let self_url = root_map.get("$self").and_then(J::as_str).map(String::from);
197
198    // 8. Build the IR and finalize (sort + topo).
199    let mut ir = Ir {
200        info: ctx.info.take().unwrap_or(ApiInfo {
201            title: String::new(),
202            version: String::new(),
203            description: None,
204            summary: None,
205            terms_of_service: None,
206            contact: None,
207            license_name: None,
208            license_url: None,
209            license_identifier: None,
210            extensions: vec![],
211        }),
212        operations: std::mem::take(&mut ctx.operations),
213        types: ctx.types.values().cloned().collect::<Vec<_>>(),
214        security_schemes: std::mem::take(&mut ctx.security_schemes),
215        servers: std::mem::take(&mut ctx.servers),
216        webhooks: std::mem::take(&mut ctx.webhooks),
217        external_docs: root_external_docs,
218        tags,
219        json_schema_dialect,
220        self_url,
221        values: std::mem::take(&mut ctx.values).finish(),
222    };
223    let mut diagnostics = std::mem::take(&mut ctx.diagnostics);
224    diagnostics.extend(finalize::canonicalize(&mut ir));
225
226    Ok(ParseOutput {
227        spec: Some(ir),
228        diagnostics,
229    })
230}
231
232/// Walk the top-level `tags: []` array into `Ir.tags` records. The
233/// 3.2 `parent` / `kind` / `summary` fields are preserved; tags whose
234/// `parent` doesn't reference a declared sibling drop the parent ref
235/// with `parser/W-TAG-PARENT-DANGLING` so generators that render tag
236/// trees don't see broken nesting.
237fn parse_tags(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) -> Vec<Tag> {
238    let Some(J::Array(tags)) = root.get("tags") else {
239        return Vec::new();
240    };
241    let mut out: Vec<Tag> = Vec::new();
242    let mut declared_names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
243    ptr.with_token("tags", |ptr| {
244        // Pass 1: collect declared names so the parent-ref check below
245        // can validate references regardless of declaration order.
246        for tag in tags.iter() {
247            if let Some(name) = tag
248                .as_object()
249                .and_then(|m| m.get("name"))
250                .and_then(J::as_str)
251            {
252                declared_names.insert(name.to_string());
253            }
254        }
255        for (i, tag) in tags.iter().enumerate() {
256            ptr.with_index(i, |ptr| {
257                let Some(map) = tag.as_object() else {
258                    ctx.push_diag(diag::err(
259                        diag::E_INVALID_TYPE,
260                        "tag must be an object",
261                        ptr.loc(ctx.file),
262                    ));
263                    return;
264                };
265                let Some(name) = map.get("name").and_then(J::as_str) else {
266                    ctx.push_diag(diag::err(
267                        diag::E_MISSING_FIELD,
268                        "tag is missing required `name`",
269                        ptr.loc(ctx.file),
270                    ));
271                    return;
272                };
273                let summary = map.get("summary").and_then(J::as_str).map(String::from);
274                let description = map.get("description").and_then(J::as_str).map(String::from);
275                let external_docs = parse_external_docs(ctx, map.get("externalDocs"), ptr);
276                let kind = map.get("kind").and_then(J::as_str).map(String::from);
277                let parent_raw = map.get("parent").and_then(J::as_str).map(String::from);
278                let parent = match parent_raw {
279                    Some(p) if !declared_names.contains(&p) => {
280                        ctx.push_diag(diag::warn(
281                            diag::W_TAG_PARENT_DANGLING,
282                            format!(
283                                "tag `{name}` references parent `{p}`, which is not declared in \
284                                 the top-level `tags` array; dropping the parent reference."
285                            ),
286                            ptr.loc(ctx.file),
287                        ));
288                        None
289                    }
290                    other => other,
291                };
292                let extensions = operations::collect_extensions(ctx, map, ptr);
293                out.push(Tag {
294                    name: name.to_string(),
295                    summary,
296                    description,
297                    external_docs,
298                    parent,
299                    kind,
300                    extensions,
301                });
302            });
303        }
304    });
305    // Determinism: sort by name. `Operation.tags` stays in declared order.
306    out.sort_by(|a, b| a.name.cmp(&b.name));
307    out
308}
309
310/// Versions the parser knows how to walk. Adding a new entry is the
311/// single edit needed to opt a future version into the existing pipeline;
312/// the per-feature differences are gated inside the walkers.
313const ACCEPTED_VERSION_PREFIXES: &[&str] = &["3.0.", "3.1.", "3.2."];
314
315/// Walk OAS `example` (3.0 single-literal) and `examples` (3.1+ map)
316/// off a schema / parameter / media-type entry. Returns the merged
317/// list with the 3.0 `example` stored under the synthetic key
318/// `"_default"` so generators have one shape to read.
319///
320/// `$ref` into `components.examples.<Name>` resolves through the
321/// existing ref machinery. Example values (scalar or compound) are
322/// interned into [`Ctx::values`]; the only `W-EXAMPLE-DROPPED` warning
323/// remaining covers `value` + `externalValue` co-declaration.
324pub(crate) fn parse_examples(
325    ctx: &mut Ctx,
326    map: &serde_json::Map<String, J>,
327    ptr: &mut Ptr,
328) -> Vec<(String, Example)> {
329    let mut out = Vec::new();
330    // 3.0 single-form `example: <literal>`. Stored under "_default".
331    if let Some(raw) = map.get("example") {
332        ptr.with_token("example", |_ptr| {
333            let value = Some(ctx.values.intern_json(raw));
334            out.push((
335                "_default".to_string(),
336                Example {
337                    summary: None,
338                    description: None,
339                    value,
340                    external_value: None,
341                    data_value: None,
342                    serialized_value: None,
343                },
344            ));
345        });
346    }
347    // 3.1+ keyed `examples: { name: ExampleObject | $ref }`.
348    if let Some(J::Object(named)) = map.get("examples") {
349        ptr.with_token("examples", |ptr| {
350            for (name, entry) in named {
351                ptr.with_token(name, |ptr| {
352                    crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
353                        let Some(emap) = resolved.as_object() else {
354                            ctx.push_diag(diag::err(
355                                diag::E_INVALID_TYPE,
356                                "example must be an object",
357                                ptr.loc(ctx.file),
358                            ));
359                            return Some(());
360                        };
361                        let summary = emap.get("summary").and_then(J::as_str).map(String::from);
362                        let description = emap
363                            .get("description")
364                            .and_then(J::as_str)
365                            .map(String::from);
366                        let external_value = emap
367                            .get("externalValue")
368                            .and_then(J::as_str)
369                            .map(String::from);
370                        let value = emap.get("value").map(|raw| ctx.values.intern_json(raw));
371                        // OAS 3.2 added `dataValue` (parsed form) and
372                        // `serializedValue` (wire form) as a refinement
373                        // of `value`. Both compound and scalar shapes
374                        // survive via the value pool.
375                        let data_value =
376                            emap.get("dataValue").map(|raw| ctx.values.intern_json(raw));
377                        let serialized_value = emap
378                            .get("serializedValue")
379                            .and_then(J::as_str)
380                            .map(String::from);
381                        if value.is_some() && external_value.is_some() {
382                            ctx.push_diag(diag::err(
383                                diag::E_EXAMPLE_VALUE_CONFLICT,
384                                format!(
385                                    "example `{name}` declares both `value` and `externalValue`; \
386                                     OAS §4.7.20 makes them mutually exclusive. Keeping `value`."
387                                ),
388                                ptr.loc(ctx.file),
389                            ));
390                        }
391                        let kept_external = if value.is_some() {
392                            None
393                        } else {
394                            external_value
395                        };
396                        out.push((
397                            name.clone(),
398                            Example {
399                                summary,
400                                description,
401                                value,
402                                external_value: kept_external,
403                                data_value,
404                                serialized_value,
405                            },
406                        ));
407                        Some(())
408                    });
409                });
410            }
411        });
412    }
413    out
414}
415
416/// Scan `components.pathItems` and emit
417/// `parser/W-COMPONENT-PATH-ITEM-UNUSED` for every entry that wasn't
418/// `$ref`'d from `paths`, `webhooks`, or any callback. Tracking lives
419/// in `Ctx::referenced_component_path_items`, populated by
420/// `with_resolved_object` whenever it resolves a fragment of the form
421/// `/components/pathItems/<name>` against the main spec.
422fn scan_unused_component_path_items(
423    ctx: &mut Ctx,
424    root: &serde_json::Map<String, J>,
425    ptr: &mut Ptr,
426) {
427    let Some(J::Object(components)) = root.get("components") else {
428        return;
429    };
430    let Some(J::Object(path_items)) = components.get("pathItems") else {
431        return;
432    };
433    ptr.with_token("components", |ptr| {
434        ptr.with_token("pathItems", |ptr| {
435            for name in path_items.keys() {
436                if !ctx.referenced_component_path_items.contains(name) {
437                    ptr.with_token(name, |ptr| {
438                        ctx.push_diag(diag::warn(
439                            diag::W_COMPONENT_PATH_ITEM_UNUSED,
440                            format!(
441                                "components.pathItems.`{name}` is declared but never \
442                                 referenced from paths, webhooks, or callbacks. The \
443                                 declaration is silently invisible to generators."
444                            ),
445                            ptr.loc(ctx.file),
446                        ));
447                    });
448                }
449            }
450        });
451    });
452}
453
454/// Scan 3.2 `components.mediaTypes` and emit
455/// `parser/W-COMPONENT-MEDIA-TYPE-UNUSED` for every entry that wasn't
456/// `$ref`'d from any request body / response content. Tracking lives
457/// in `Ctx::referenced_component_media_types`, populated by
458/// `with_resolved_object` whenever it resolves a fragment of the form
459/// `/components/mediaTypes/<name>` against the main spec.
460fn scan_unused_component_media_types(
461    ctx: &mut Ctx,
462    root: &serde_json::Map<String, J>,
463    ptr: &mut Ptr,
464) {
465    let Some(J::Object(components)) = root.get("components") else {
466        return;
467    };
468    let Some(J::Object(media_types)) = components.get("mediaTypes") else {
469        return;
470    };
471    ptr.with_token("components", |ptr| {
472        ptr.with_token("mediaTypes", |ptr| {
473            for name in media_types.keys() {
474                if !ctx.referenced_component_media_types.contains(name) {
475                    ptr.with_token(name, |ptr| {
476                        ctx.push_diag(diag::warn(
477                            diag::W_COMPONENT_MEDIA_TYPE_UNUSED,
478                            format!(
479                                "components.mediaTypes.`{name}` is declared but never \
480                                 referenced. The declaration is silently invisible to \
481                                 generators."
482                            ),
483                            ptr.loc(ctx.file),
484                        ));
485                    });
486                }
487            }
488        });
489    });
490}
491
492/// Walk an `operation.callbacks` map (or a `components.callbacks`
493/// entry resolved via `$ref`). The OAS shape is
494/// `callbacks: { <name>: { <expression>: PathItem } }`; the IR
495/// flattens this into a `Vec<Callback>` where each element pairs a
496/// name with one runtime expression. Each path item is walked through
497/// `parse_path_item` so the inner operations get the same treatment as
498/// top-level paths (operationId dedup, params merging, etc.).
499pub(crate) fn parse_callbacks(
500    ctx: &mut Ctx,
501    value: Option<&J>,
502    ptr: &mut Ptr,
503    seen_op_ids: &mut std::collections::HashSet<String>,
504) -> Vec<Callback> {
505    let Some(J::Object(named)) = value else {
506        return Vec::new();
507    };
508    let mut out = Vec::new();
509    ptr.with_token("callbacks", |ptr| {
510        for (name, entry) in named {
511            ptr.with_token(name, |ptr| {
512                crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
513                    let Some(emap) = resolved.as_object() else {
514                        ctx.push_diag(diag::err(
515                            diag::E_INVALID_TYPE,
516                            "callback must be an object",
517                            ptr.loc(ctx.file),
518                        ));
519                        return Some(());
520                    };
521                    // Top-level extensions on the callback wrapper.
522                    let extensions = operations::collect_extensions(ctx, emap, ptr);
523                    for (expr, path_item) in emap {
524                        // x-* keys are extensions on the callback
525                        // wrapper, not expression keys.
526                        if expr.starts_with("x-") {
527                            continue;
528                        }
529                        ptr.with_token(expr, |ptr| {
530                            let ops =
531                                operations::parse_path_item(ctx, expr, path_item, ptr, seen_op_ids);
532                            // Operations live in `Ir.operations` (the
533                            // global pool); the callback references
534                            // them by id. Push onto the context so
535                            // they show up in the final IR.
536                            let operation_ids: Vec<String> =
537                                ops.iter().map(|o| o.id.clone()).collect();
538                            ctx.operations.extend(ops);
539                            out.push(Callback {
540                                name: name.clone(),
541                                expression: expr.clone(),
542                                operation_ids,
543                                extensions: extensions.clone(),
544                            });
545                        });
546                    }
547                    Some(())
548                });
549            });
550        }
551    });
552    out
553}
554
555/// Walk a `response.links` map. Returns the parsed list (named,
556/// ordered). `$ref` into `components.links.<Name>` resolves through
557/// the existing ref machinery. Compound runtime-expression values
558/// (object / array literals) drop with the new
559/// `parser/W-LINK-VALUE-DROPPED` warning.
560pub(crate) fn parse_links(ctx: &mut Ctx, value: Option<&J>, ptr: &mut Ptr) -> Vec<(String, Link)> {
561    let Some(J::Object(named)) = value else {
562        return Vec::new();
563    };
564    let mut out = Vec::new();
565    ptr.with_token("links", |ptr| {
566        for (name, entry) in named {
567            ptr.with_token(name, |ptr| {
568                crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
569                    let Some(lmap) = resolved.as_object() else {
570                        ctx.push_diag(diag::err(
571                            diag::E_INVALID_TYPE,
572                            "link must be an object",
573                            ptr.loc(ctx.file),
574                        ));
575                        return Some(());
576                    };
577                    let operation_ref = lmap
578                        .get("operationRef")
579                        .and_then(J::as_str)
580                        .map(String::from);
581                    let raw_operation_id = lmap
582                        .get("operationId")
583                        .and_then(J::as_str)
584                        .map(String::from);
585                    let operation_id = if operation_ref.is_some() && raw_operation_id.is_some() {
586                        ctx.push_diag(diag::err(
587                            diag::E_LINK_OP_CONFLICT,
588                            format!(
589                                "link `{name}` declares both `operationRef` and `operationId`; \
590                                 OAS §4.7.21 makes them mutually exclusive. Keeping `operationRef`."
591                            ),
592                            ptr.loc(ctx.file),
593                        ));
594                        None
595                    } else {
596                        raw_operation_id
597                    };
598                    let parameters = lmap
599                        .get("parameters")
600                        .and_then(|v| v.as_object())
601                        .map(|m| {
602                            m.iter()
603                                .map(|(k, raw)| (k.clone(), ctx.values.intern_json(raw)))
604                                .collect()
605                        })
606                        .unwrap_or_default();
607                    let request_body = lmap
608                        .get("requestBody")
609                        .map(|raw| ctx.values.intern_json(raw));
610                    let description = lmap
611                        .get("description")
612                        .and_then(J::as_str)
613                        .map(String::from);
614                    let server = lmap.get("server").and_then(|s| {
615                        s.as_object().and_then(|m| {
616                            let url = m.get("url").and_then(J::as_str)?;
617                            let description =
618                                m.get("description").and_then(J::as_str).map(String::from);
619                            let server_name = m.get("name").and_then(J::as_str).map(String::from);
620                            Some(Server {
621                                url: url.to_string(),
622                                description,
623                                name: server_name,
624                                variables: Vec::new(),
625                                extensions: Vec::new(),
626                            })
627                        })
628                    });
629                    let extensions = operations::collect_extensions(ctx, lmap, ptr);
630                    out.push((
631                        name.clone(),
632                        Link {
633                            operation_ref,
634                            operation_id,
635                            parameters,
636                            request_body,
637                            description,
638                            server,
639                            extensions,
640                        },
641                    ));
642                    Some(())
643                });
644            });
645        }
646    });
647    out
648}
649
650/// OAS Schema Object's `xml` block. Returns `None` when the spec
651/// didn't declare an `xml` field. Defaults match OAS: `attribute` and
652/// `wrapped` are `false`. `x-*` extensions on the xml block survive
653/// via the existing `collect_extensions` path.
654pub(crate) fn parse_xml(
655    ctx: &mut Ctx,
656    map: &serde_json::Map<String, J>,
657    ptr: &mut Ptr,
658) -> Option<XmlObject> {
659    let xml = map.get("xml")?;
660    let xml_map = xml.as_object()?;
661    let mut out = None;
662    ptr.with_token("xml", |ptr| {
663        let name = xml_map.get("name").and_then(J::as_str).map(String::from);
664        let namespace = xml_map
665            .get("namespace")
666            .and_then(J::as_str)
667            .map(String::from);
668        let prefix = xml_map.get("prefix").and_then(J::as_str).map(String::from);
669        let attribute = xml_map
670            .get("attribute")
671            .and_then(J::as_bool)
672            .unwrap_or(false);
673        let wrapped = xml_map.get("wrapped").and_then(J::as_bool).unwrap_or(false);
674        let text = xml_map.get("text").and_then(J::as_bool).unwrap_or(false);
675        let ordered = xml_map.get("ordered").and_then(J::as_bool).unwrap_or(false);
676        let extensions = operations::collect_extensions(ctx, xml_map, ptr);
677        out = Some(XmlObject {
678            name,
679            namespace,
680            prefix,
681            attribute,
682            wrapped,
683            text,
684            ordered,
685            extensions,
686        });
687    });
688    out
689}
690
691/// JSON Schema `default` for a schema or property. Interns the value
692/// (scalar or compound) into [`Ctx::values`] and returns its
693/// [`forge_ir::ValueRef`]. Returns `None` only when the field is absent.
694pub(crate) fn parse_default(
695    ctx: &mut Ctx,
696    map: &serde_json::Map<String, J>,
697    _ptr: &mut Ptr,
698    _site: &str,
699) -> Option<forge_ir::ValueRef> {
700    let raw = map.get("default")?;
701    Some(ctx.values.intern_json(raw))
702}
703
704/// Walk an OAS ExternalDocumentation Object. `url` is the only
705/// required field; documents that omit it surface
706/// `parser/W-EXTERNAL-DOCS-NO-URL` and the block is dropped. Used
707/// at root, per-operation, and per-schema sites.
708pub(crate) fn parse_external_docs(
709    ctx: &mut Ctx,
710    value: Option<&J>,
711    ptr: &mut Ptr,
712) -> Option<ExternalDocs> {
713    let map = value?.as_object()?;
714    let mut out = None;
715    ptr.with_token("externalDocs", |ptr| {
716        let Some(url) = map.get("url").and_then(J::as_str) else {
717            ctx.push_diag(diag::warn(
718                diag::W_EXTERNAL_DOCS_NO_URL,
719                "externalDocs is missing required `url`; dropping the block.",
720                ptr.loc(ctx.file),
721            ));
722            return;
723        };
724        let description = map.get("description").and_then(J::as_str).map(String::from);
725        out = Some(ExternalDocs {
726            description,
727            url: url.to_string(),
728        });
729    });
730    out
731}
732
733fn check_version(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) -> bool {
734    // OpenAPI 2.0 / Swagger uses a `swagger` field instead of `openapi`.
735    // Surface it specifically so users get a clear message.
736    if root.contains_key("swagger") {
737        ptr.with_token("swagger", |ptr| {
738            ctx.push_diag(diag::err(
739                diag::E_UNSUPPORTED_VERSION,
740                "OpenAPI 2.0 (Swagger) is not supported and is not on the roadmap. \
741                 Convert to OpenAPI 3.0 upstream (e.g. `swagger2openapi`) before invoking forge.",
742                ptr.loc(ctx.file),
743            ));
744        });
745        return false;
746    }
747    let version = root.get("openapi").and_then(J::as_str);
748    match version {
749        Some(v) if ACCEPTED_VERSION_PREFIXES.iter().any(|p| v.starts_with(p)) => {
750            // OAS 3.0 forbade `$ref` siblings; 3.1+ inherits JSON
751            // Schema 2020-12's allowance. The schema walker reads this
752            // bit to pick the right diagnostic.
753            ctx.is_oas_3_0 = v.starts_with("3.0.");
754            true
755        }
756        Some(other) => {
757            let msg = if other.starts_with("2.") || other.starts_with("1.") {
758                format!(
759                    "OpenAPI {other} is not supported and is not on the roadmap. \
760                     Convert to OpenAPI 3.x upstream before invoking forge."
761                )
762            } else {
763                format!("unsupported OpenAPI version `{other}`; expected 3.0.x / 3.1.x / 3.2.x")
764            };
765            ptr.with_token("openapi", |ptr| {
766                ctx.push_diag(diag::err(
767                    diag::E_UNSUPPORTED_VERSION,
768                    msg,
769                    ptr.loc(ctx.file),
770                ));
771            });
772            false
773        }
774        None => {
775            ctx.push_diag(diag::err(
776                diag::E_MISSING_FIELD,
777                "missing required `openapi` field",
778                SpecLocation::new(""),
779            ));
780            false
781        }
782    }
783}
784
785fn parse_info(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
786    let Some(J::Object(info)) = root.get("info") else {
787        ctx.push_diag(diag::err(
788            diag::E_MISSING_FIELD,
789            "missing required `info` object",
790            ptr.loc(ctx.file),
791        ));
792        return;
793    };
794    ptr.with_token("info", |ptr| {
795        let title = info.get("title").and_then(J::as_str).unwrap_or_else(|| {
796            ctx.push_diag(diag::err(
797                diag::E_MISSING_FIELD,
798                "info is missing `title`",
799                ptr.loc(ctx.file),
800            ));
801            ""
802        });
803        let version = info.get("version").and_then(J::as_str).unwrap_or_else(|| {
804            ctx.push_diag(diag::err(
805                diag::E_MISSING_FIELD,
806                "info is missing `version`",
807                ptr.loc(ctx.file),
808            ));
809            ""
810        });
811        let description = info
812            .get("description")
813            .and_then(J::as_str)
814            .map(String::from);
815        let summary = info.get("summary").and_then(J::as_str).map(String::from);
816        let terms_of_service = info
817            .get("termsOfService")
818            .and_then(J::as_str)
819            .map(String::from);
820        let contact =
821            info.get("contact")
822                .and_then(|v| v.as_object())
823                .and_then(|m| -> Option<Contact> {
824                    let name = m.get("name").and_then(J::as_str).map(String::from);
825                    let url = m.get("url").and_then(J::as_str).map(String::from);
826                    let email = m.get("email").and_then(J::as_str).map(String::from);
827                    if name.is_none() && url.is_none() && email.is_none() {
828                        None
829                    } else {
830                        Some(Contact { name, url, email })
831                    }
832                });
833        let license = info.get("license").and_then(|l| l.as_object());
834        let license_name = license
835            .and_then(|m| m.get("name"))
836            .and_then(J::as_str)
837            .map(String::from);
838        let license_url = license
839            .and_then(|m| m.get("url"))
840            .and_then(J::as_str)
841            .map(String::from);
842        let license_identifier = license
843            .and_then(|m| m.get("identifier"))
844            .and_then(J::as_str)
845            .map(String::from);
846        let extensions = operations::collect_extensions(ctx, info, ptr);
847        ctx.info = Some(ApiInfo {
848            title: title.to_string(),
849            version: version.to_string(),
850            description,
851            summary,
852            terms_of_service,
853            contact,
854            license_name,
855            license_url,
856            license_identifier,
857            extensions,
858        });
859    });
860}
861
862fn parse_servers(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
863    let servers = parse_servers_array(ctx, root.get("servers"), ptr);
864    ctx.servers.extend(servers);
865}
866
867/// Walk a `servers` array off any host (root, path-item, or operation)
868/// and return the parsed list. Empty / missing input returns `[]`. Used
869/// by `parse_servers` for the root list and by the operations walker
870/// for path-item / per-operation overrides.
871pub(crate) fn parse_servers_array(ctx: &mut Ctx, value: Option<&J>, ptr: &mut Ptr) -> Vec<Server> {
872    let Some(J::Array(items)) = value else {
873        return Vec::new();
874    };
875    let mut out = Vec::new();
876    ptr.with_token("servers", |ptr| {
877        for (i, item) in items.iter().enumerate() {
878            ptr.with_index(i, |ptr| {
879                let Some(map) = item.as_object() else {
880                    ctx.push_diag(diag::err(
881                        diag::E_INVALID_TYPE,
882                        "server must be an object",
883                        ptr.loc(ctx.file),
884                    ));
885                    return;
886                };
887                let Some(url) = map.get("url").and_then(J::as_str) else {
888                    ctx.push_diag(diag::err(
889                        diag::E_MISSING_FIELD,
890                        "server is missing `url`",
891                        ptr.loc(ctx.file),
892                    ));
893                    return;
894                };
895                let description = map.get("description").and_then(J::as_str).map(String::from);
896                let server_name = map.get("name").and_then(J::as_str).map(String::from);
897                let mut variables: Vec<(String, ServerVariable)> = Vec::new();
898                if let Some(J::Object(vars)) = map.get("variables") {
899                    ptr.with_token("variables", |ptr| {
900                        for (name, v) in vars {
901                            ptr.with_token(name, |ptr| {
902                                let Some(vmap) = v.as_object() else { return };
903                                let Some(default) = vmap.get("default").and_then(J::as_str) else {
904                                    return;
905                                };
906                                let var_extensions = operations::collect_extensions(ctx, vmap, ptr);
907                                variables.push((
908                                    name.clone(),
909                                    ServerVariable {
910                                        default: default.to_string(),
911                                        r#enum: vmap.get("enum").and_then(|e| {
912                                            e.as_array().map(|arr| {
913                                                arr.iter()
914                                                    .filter_map(|v| v.as_str().map(String::from))
915                                                    .collect()
916                                            })
917                                        }),
918                                        description: vmap
919                                            .get("description")
920                                            .and_then(J::as_str)
921                                            .map(String::from),
922                                        extensions: var_extensions,
923                                    },
924                                ));
925                            });
926                        }
927                    });
928                }
929                let extensions = operations::collect_extensions(ctx, map, ptr);
930                out.push(Server {
931                    url: url.to_string(),
932                    description,
933                    name: server_name,
934                    variables,
935                    extensions,
936                });
937            });
938        }
939    });
940    out
941}
942
943fn register_component_schemas(ctx: &mut Ctx, root: &serde_json::Map<String, J>) {
944    let Some(J::Object(components)) = root.get("components") else {
945        return;
946    };
947    let Some(J::Object(schemas)) = components.get("schemas") else {
948        return;
949    };
950    for name in schemas.keys() {
951        let id = sanitize::ident(name);
952        ctx.refs_mut().register(&id);
953    }
954}
955
956fn walk_component_schemas(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
957    let Some(J::Object(components)) = root.get("components") else {
958        return;
959    };
960    let Some(J::Object(schemas)) = components.get("schemas") else {
961        return;
962    };
963    // Pre-register every `components.schemas.<X>: { $ref: ext.json#/Y }`
964    // mapping before walking. Without this step, a sibling component
965    // walked earlier whose body $ref's the same external schema would
966    // trigger that schema's walk under an `Inline` hint and pollute the
967    // dedup map with the prefixed id; the later `components.schemas.X`
968    // walk would then dedup-hit and inherit the wrong (prefixed) id.
969    pre_register_external_named_hints(ctx, schemas);
970    let order = order_components_by_allof(schemas);
971    ptr.with_token("components", |ptr| {
972        ptr.with_token("schemas", |ptr| {
973            for name in &order {
974                let Some(schema) = schemas.get(name) else {
975                    continue;
976                };
977                ptr.with_token(name, |ptr| {
978                    // Push the in-progress walk into `walking` so a
979                    // cross-file ref that loops back at this schema
980                    // recognises the cycle and returns the id without
981                    // re-walking. The lazy walker uses the same key shape.
982                    let key = (
983                        ctx.current_doc.clone(),
984                        format!("/components/schemas/{name}"),
985                    );
986                    ctx.walking.insert(key.clone());
987                    let _ = parse_schema(ctx, schema, ptr, NameHint::Named(name.clone()));
988                    ctx.walking.remove(&key);
989                });
990            }
991        });
992    });
993}
994
995/// Walk `components.schemas` and seed the external-ref dedup map for
996/// every entry whose body is a single `$ref` to a cross-document schema.
997/// The seeded id is the component's *Named* id (e.g. `Pet`), so any
998/// indirect resolution to the same target during sibling-schema walking
999/// returns `Pet` instead of synthesising a fresh `<docprefix>Pet`.
1000///
1001/// This only seeds *direct* `$ref`s — schemas with `allOf`/`oneOf`/etc.
1002/// that internally use `$ref` get their dedup entry filled by the lazy
1003/// walker as usual.
1004fn pre_register_external_named_hints(ctx: &mut Ctx, schemas: &serde_json::Map<String, J>) {
1005    let current_doc = ctx.current_doc.clone();
1006    for (name, schema) in schemas {
1007        let Some(map) = schema.as_object() else {
1008            continue;
1009        };
1010        let Some(J::String(raw)) = map.get("$ref") else {
1011            continue;
1012        };
1013        let (file_part, fragment) = crate::external::split_ref(raw);
1014        if file_part.is_empty() || crate::external::is_url(file_part) {
1015            continue;
1016        }
1017        let Ok(loaded) = ctx.resolver.load(raw, &current_doc) else {
1018            continue;
1019        };
1020        let canonical = loaded.canonical_path.clone();
1021        crate::schema::ensure_doc_registered(ctx, &canonical, &loaded.root);
1022        ctx.external_ref_to_id
1023            .entry((canonical, fragment.to_string()))
1024            .or_insert_with(|| crate::sanitize::ident(name));
1025    }
1026}
1027
1028/// Order component schemas so any schema that uses `allOf: [{ $ref: X }]`
1029/// is walked *after* `X`. Cycles fall back to alphabetical order at the
1030/// end (the parser still produces best-effort output, and `finalize`
1031/// flags the cycle separately).
1032fn order_components_by_allof(schemas: &serde_json::Map<String, J>) -> Vec<String> {
1033    use std::collections::{BTreeMap, BTreeSet};
1034
1035    let mut deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1036    for (name, schema) in schemas {
1037        let mut targets: BTreeSet<String> = BTreeSet::new();
1038        collect_allof_ref_targets(schema, &mut targets);
1039        targets.retain(|t| schemas.contains_key(t) && t != name);
1040        deps.insert(name.clone(), targets);
1041    }
1042
1043    let mut visited: BTreeSet<String> = BTreeSet::new();
1044    let mut ordered: Vec<String> = Vec::new();
1045    let mut all_names: Vec<String> = schemas.keys().cloned().collect();
1046    all_names.sort();
1047
1048    loop {
1049        let next = all_names.iter().find(|n| {
1050            !visited.contains(*n)
1051                && deps
1052                    .get(*n)
1053                    .map(|d| d.iter().all(|t| visited.contains(t)))
1054                    .unwrap_or(true)
1055        });
1056        match next {
1057            Some(name) => {
1058                let n = name.clone();
1059                visited.insert(n.clone());
1060                ordered.push(n);
1061            }
1062            None => break,
1063        }
1064    }
1065    // Cycle remainder: append alphabetically.
1066    for n in all_names {
1067        if !visited.contains(&n) {
1068            ordered.push(n);
1069        }
1070    }
1071    ordered
1072}
1073
1074fn collect_allof_ref_targets(value: &J, out: &mut std::collections::BTreeSet<String>) {
1075    let Some(map) = value.as_object() else {
1076        return;
1077    };
1078    if let Some(J::Array(parts)) = map.get("allOf") {
1079        for part in parts {
1080            if let Some(rs) = part
1081                .as_object()
1082                .and_then(|m| m.get("$ref"))
1083                .and_then(|r| r.as_str())
1084            {
1085                if let Some(name) = rs.strip_prefix("#/components/schemas/") {
1086                    out.insert(name.to_string());
1087                }
1088            }
1089        }
1090    }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096
1097    #[test]
1098    fn empty_input_errors() {
1099        let err = parse_str("").unwrap_err();
1100        matches!(err, ParseError::Empty);
1101    }
1102
1103    #[test]
1104    fn invalid_json_errors() {
1105        let err = parse_str("{not json").unwrap_err();
1106        matches!(err, ParseError::InvalidJson(_));
1107    }
1108
1109    #[test]
1110    fn root_array_errors() {
1111        let err = parse_str("[]").unwrap_err();
1112        matches!(err, ParseError::NotObject);
1113    }
1114
1115    #[test]
1116    fn unsupported_version_diagnostic() {
1117        // 4.0 is not on the roadmap; it should fail-fast.
1118        let src = r#"{"openapi":"4.0.0","info":{"title":"x","version":"1"},"paths":{}}"#;
1119        let out = parse_str(src).unwrap();
1120        assert!(out.spec.is_none());
1121        assert_eq!(out.diagnostics.len(), 1);
1122        assert_eq!(out.diagnostics[0].code, diag::E_UNSUPPORTED_VERSION);
1123    }
1124
1125    #[test]
1126    fn minimal_spec_round_trips() {
1127        let src = r#"{
1128            "openapi":"3.0.3",
1129            "info":{"title":"t","version":"1"},
1130            "paths":{}
1131        }"#;
1132        let out = parse_str(src).unwrap();
1133        let ir = out.spec.unwrap();
1134        assert_eq!(ir.info.title, "t");
1135        assert!(ir.operations.is_empty());
1136        assert!(ir.types.is_empty());
1137    }
1138
1139    #[test]
1140    fn info_full_block_populates_every_field() {
1141        let src = r#"{
1142            "openapi":"3.1.0",
1143            "info":{
1144                "title":"t",
1145                "version":"1",
1146                "summary":"s",
1147                "description":"d",
1148                "termsOfService":"https://tos.example",
1149                "contact":{
1150                    "name":"API Team",
1151                    "url":"https://example.com",
1152                    "email":"team@example.com"
1153                },
1154                "license":{
1155                    "name":"Apache 2.0",
1156                    "url":"https://www.apache.org/licenses/LICENSE-2.0",
1157                    "identifier":"Apache-2.0"
1158                }
1159            },
1160            "paths":{}
1161        }"#;
1162        let ir = parse_str(src).unwrap().spec.unwrap();
1163        assert_eq!(ir.info.summary.as_deref(), Some("s"));
1164        assert_eq!(ir.info.description.as_deref(), Some("d"));
1165        assert_eq!(
1166            ir.info.terms_of_service.as_deref(),
1167            Some("https://tos.example")
1168        );
1169        let contact = ir.info.contact.expect("contact populated");
1170        assert_eq!(contact.name.as_deref(), Some("API Team"));
1171        assert_eq!(contact.url.as_deref(), Some("https://example.com"));
1172        assert_eq!(contact.email.as_deref(), Some("team@example.com"));
1173        assert_eq!(ir.info.license_name.as_deref(), Some("Apache 2.0"));
1174        assert_eq!(
1175            ir.info.license_url.as_deref(),
1176            Some("https://www.apache.org/licenses/LICENSE-2.0")
1177        );
1178        assert_eq!(ir.info.license_identifier.as_deref(), Some("Apache-2.0"));
1179    }
1180
1181    #[test]
1182    fn info_contact_object_with_no_known_keys_is_none() {
1183        // OAS allows `x-*` extensions on Contact; with no recognised
1184        // fields, the IR should leave `contact` as None rather than
1185        // emitting an empty Contact record.
1186        let src = r#"{
1187            "openapi":"3.0.0",
1188            "info":{
1189                "title":"t",
1190                "version":"1",
1191                "contact":{ "x-vendor": "acme" }
1192            },
1193            "paths":{}
1194        }"#;
1195        let ir = parse_str(src).unwrap().spec.unwrap();
1196        assert!(ir.info.contact.is_none());
1197    }
1198
1199    #[test]
1200    fn external_docs_populated_at_root_operation_and_schema() {
1201        let src = r#"{
1202            "openapi":"3.0.3",
1203            "info":{"title":"t","version":"1"},
1204            "externalDocs":{"description":"top","url":"https://example.com"},
1205            "paths":{
1206                "/x":{
1207                    "get":{
1208                        "operationId":"getX",
1209                        "externalDocs":{"url":"https://example.com/op"},
1210                        "responses":{"200":{"description":"ok"}}
1211                    }
1212                }
1213            },
1214            "components":{
1215                "schemas":{
1216                    "Foo":{
1217                        "type":"object",
1218                        "externalDocs":{"description":"d","url":"https://example.com/foo"}
1219                    }
1220                }
1221            }
1222        }"#;
1223        let ir = parse_str(src).unwrap().spec.unwrap();
1224        let root = ir.external_docs.expect("root externalDocs");
1225        assert_eq!(root.url, "https://example.com");
1226        assert_eq!(root.description.as_deref(), Some("top"));
1227
1228        let op_docs = ir.operations[0]
1229            .external_docs
1230            .as_ref()
1231            .expect("op externalDocs");
1232        assert_eq!(op_docs.url, "https://example.com/op");
1233        assert!(op_docs.description.is_none());
1234
1235        let foo = ir.types.iter().find(|t| t.id == "Foo").expect("Foo type");
1236        let schema_docs = foo.external_docs.as_ref().expect("schema externalDocs");
1237        assert_eq!(schema_docs.url, "https://example.com/foo");
1238        assert_eq!(schema_docs.description.as_deref(), Some("d"));
1239    }
1240
1241    #[test]
1242    fn external_docs_missing_url_warns_and_drops() {
1243        let src = r#"{
1244            "openapi":"3.0.3",
1245            "info":{"title":"t","version":"1"},
1246            "externalDocs":{"description":"oops"},
1247            "paths":{}
1248        }"#;
1249        let out = parse_str(src).unwrap();
1250        let ir = out.spec.unwrap();
1251        assert!(ir.external_docs.is_none());
1252        assert!(out
1253            .diagnostics
1254            .iter()
1255            .any(|d| d.code == diag::W_EXTERNAL_DOCS_NO_URL));
1256    }
1257
1258    #[test]
1259    fn webhooks_carry_routing_name_and_multiple_methods() {
1260        let src = r#"{
1261            "openapi":"3.1.0",
1262            "info":{"title":"t","version":"1"},
1263            "paths":{},
1264            "webhooks":{
1265                "newPet":{
1266                    "post":{
1267                        "operationId":"newPetCreated",
1268                        "responses":{"200":{"description":"ok"}}
1269                    },
1270                    "delete":{
1271                        "operationId":"newPetDeleted",
1272                        "responses":{"200":{"description":"ok"}}
1273                    }
1274                }
1275            }
1276        }"#;
1277        let ir = parse_str(src).unwrap().spec.unwrap();
1278        assert_eq!(ir.webhooks.len(), 1);
1279        let w = &ir.webhooks[0];
1280        assert_eq!(w.name, "newPet");
1281        // Path item has both `post` and `delete`; both surface as
1282        // operations on the same Webhook.
1283        assert_eq!(w.operations.len(), 2);
1284        assert!(w.operations.iter().any(|o| o.id == "newPetCreated"));
1285        assert!(w.operations.iter().any(|o| o.id == "newPetDeleted"));
1286    }
1287
1288    #[test]
1289    fn webhooks_sort_by_name() {
1290        let src = r#"{
1291            "openapi":"3.1.0",
1292            "info":{"title":"t","version":"1"},
1293            "paths":{},
1294            "webhooks":{
1295                "zebra":{"post":{"operationId":"z","responses":{"200":{"description":"ok"}}}},
1296                "alpha":{"post":{"operationId":"a","responses":{"200":{"description":"ok"}}}}
1297            }
1298        }"#;
1299        let ir = parse_str(src).unwrap().spec.unwrap();
1300        assert_eq!(ir.webhooks[0].name, "alpha");
1301        assert_eq!(ir.webhooks[1].name, "zebra");
1302    }
1303
1304    #[test]
1305    fn response_headers_use_dedicated_header_struct() {
1306        let src = r#"{
1307            "openapi":"3.0.3",
1308            "info":{"title":"t","version":"1"},
1309            "paths":{
1310                "/x":{
1311                    "get":{
1312                        "operationId":"x",
1313                        "responses":{
1314                            "200":{
1315                                "description":"ok",
1316                                "headers":{
1317                                    "X-Trace":{
1318                                        "description":"trace id",
1319                                        "required":true,
1320                                        "schema":{"type":"string"}
1321                                    }
1322                                }
1323                            }
1324                        }
1325                    }
1326                }
1327            }
1328        }"#;
1329        let ir = parse_str(src).unwrap().spec.unwrap();
1330        let resp = &ir.operations[0].responses[0];
1331        assert_eq!(resp.headers.len(), 1);
1332        let (name, header) = &resp.headers[0];
1333        assert_eq!(name, "X-Trace");
1334        assert!(header.required);
1335        assert_eq!(header.documentation.as_deref(), Some("trace id"));
1336        // No `name`, `style`, `explode`, etc. — Header struct doesn't
1337        // carry those (they don't apply to OAS headers).
1338    }
1339
1340    #[test]
1341    fn openid_connect_security_scheme_round_trips() {
1342        let src = r#"{
1343            "openapi":"3.0.3",
1344            "info":{"title":"t","version":"1"},
1345            "paths":{},
1346            "components":{
1347                "securitySchemes":{
1348                    "oidc":{
1349                        "type":"openIdConnect",
1350                        "openIdConnectUrl":"https://example.com/.well-known/openid-configuration"
1351                    }
1352                }
1353            }
1354        }"#;
1355        let ir = parse_str(src).unwrap().spec.unwrap();
1356        let scheme = ir
1357            .security_schemes
1358            .iter()
1359            .find(|s| s.id == "oidc")
1360            .expect("oidc scheme present");
1361        match &scheme.kind {
1362            forge_ir::SecuritySchemeKind::OpenIdConnect { url } => {
1363                assert_eq!(url, "https://example.com/.well-known/openid-configuration");
1364            }
1365            other => panic!("expected OpenIdConnect, got {other:?}"),
1366        }
1367    }
1368
1369    #[test]
1370    fn openid_connect_missing_url_errors() {
1371        let src = r#"{
1372            "openapi":"3.0.3",
1373            "info":{"title":"t","version":"1"},
1374            "paths":{},
1375            "components":{
1376                "securitySchemes":{
1377                    "oidc":{"type":"openIdConnect"}
1378                }
1379            }
1380        }"#;
1381        let out = parse_str(src).unwrap();
1382        // Scheme is dropped (None return) — security_schemes empty.
1383        assert!(out.spec.unwrap().security_schemes.is_empty());
1384        // Missing-field error fires.
1385        assert!(out
1386            .diagnostics
1387            .iter()
1388            .any(|d| d.code == diag::E_MISSING_FIELD));
1389    }
1390
1391    #[test]
1392    fn ref_siblings_warn_on_oas_3_0() {
1393        let src = r##"{
1394            "openapi":"3.0.3",
1395            "info":{"title":"t","version":"1"},
1396            "paths":{},
1397            "components":{
1398                "schemas":{
1399                    "A":{"type":"string"},
1400                    "B":{"$ref":"#/components/schemas/A","description":"sibling"}
1401                }
1402            }
1403        }"##;
1404        let out = parse_str(src).unwrap();
1405        let diags = out.diagnostics;
1406        let warning = diags
1407            .iter()
1408            .find(|d| d.code == diag::W_REF_SIBLINGS_3_0)
1409            .expect("warning emitted");
1410        assert!(warning.message.contains("description"));
1411    }
1412
1413    #[test]
1414    fn ref_siblings_dont_warn_on_oas_3_1() {
1415        let src = r##"{
1416            "openapi":"3.1.0",
1417            "info":{"title":"t","version":"1"},
1418            "paths":{},
1419            "components":{
1420                "schemas":{
1421                    "A":{"type":"string"},
1422                    "B":{"$ref":"#/components/schemas/A","description":"sibling"}
1423                }
1424            }
1425        }"##;
1426        let out = parse_str(src).unwrap();
1427        assert!(!out
1428            .diagnostics
1429            .iter()
1430            .any(|d| d.code == diag::W_REF_SIBLINGS_3_0));
1431    }
1432
1433    #[test]
1434    fn ref_with_only_x_extensions_does_not_warn() {
1435        // x-* extensions are always allowed alongside $ref (per OAS).
1436        let src = r##"{
1437            "openapi":"3.0.3",
1438            "info":{"title":"t","version":"1"},
1439            "paths":{},
1440            "components":{
1441                "schemas":{
1442                    "A":{"type":"string"},
1443                    "B":{"$ref":"#/components/schemas/A","x-vendor":"acme"}
1444                }
1445            }
1446        }"##;
1447        let out = parse_str(src).unwrap();
1448        assert!(!out
1449            .diagnostics
1450            .iter()
1451            .any(|d| d.code == diag::W_REF_SIBLINGS_3_0));
1452    }
1453
1454    #[test]
1455    fn referenced_component_path_item_lands_in_operations() {
1456        let src = r##"{
1457            "openapi":"3.1.0",
1458            "info":{"title":"t","version":"1"},
1459            "paths":{
1460                "/items":{"$ref":"#/components/pathItems/ItemsPath"}
1461            },
1462            "components":{
1463                "pathItems":{
1464                    "ItemsPath":{
1465                        "get":{"operationId":"list","responses":{"200":{"description":"ok"}}}
1466                    }
1467                }
1468            }
1469        }"##;
1470        let out = parse_str(src).unwrap();
1471        let ir = out.spec.unwrap();
1472        // Operation came in via $ref.
1473        assert!(ir.operations.iter().any(|o| o.id == "list"));
1474        // No unused warning since the only declaration is referenced.
1475        assert!(!out
1476            .diagnostics
1477            .iter()
1478            .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1479    }
1480
1481    #[test]
1482    fn unused_component_path_item_warns() {
1483        let src = r##"{
1484            "openapi":"3.1.0",
1485            "info":{"title":"t","version":"1"},
1486            "paths":{},
1487            "components":{
1488                "pathItems":{
1489                    "Orphan":{
1490                        "get":{"operationId":"orphan","responses":{"200":{"description":"ok"}}}
1491                    }
1492                }
1493            }
1494        }"##;
1495        let out = parse_str(src).unwrap();
1496        let ir = out.spec.unwrap();
1497        // The orphan operation never lands in IR — only the warning.
1498        assert!(ir.operations.is_empty());
1499        assert!(out
1500            .diagnostics
1501            .iter()
1502            .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1503    }
1504
1505    #[test]
1506    fn webhook_ref_into_component_path_item_counts_as_use() {
1507        let src = r##"{
1508            "openapi":"3.1.0",
1509            "info":{"title":"t","version":"1"},
1510            "paths":{},
1511            "webhooks":{
1512                "ev":{"$ref":"#/components/pathItems/EventPath"}
1513            },
1514            "components":{
1515                "pathItems":{
1516                    "EventPath":{
1517                        "post":{"operationId":"ev","responses":{"200":{"description":"ok"}}}
1518                    }
1519                }
1520            }
1521        }"##;
1522        let out = parse_str(src).unwrap();
1523        // Webhook reference counts — no unused warning.
1524        assert!(!out
1525            .diagnostics
1526            .iter()
1527            .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1528    }
1529
1530    #[test]
1531    fn callbacks_walk_inline_and_via_ref() {
1532        let src = r##"{
1533            "openapi":"3.0.3",
1534            "info":{"title":"t","version":"1"},
1535            "paths":{
1536                "/sub":{
1537                    "post":{
1538                        "operationId":"sub",
1539                        "responses":{"200":{"description":"ok"}},
1540                        "callbacks":{
1541                            "evt":{
1542                                "{$request.body#/url}":{
1543                                    "post":{
1544                                        "operationId":"evtCb",
1545                                        "responses":{"200":{"description":"ok"}}
1546                                    }
1547                                }
1548                            },
1549                            "shared":{"$ref":"#/components/callbacks/Shared"}
1550                        }
1551                    }
1552                }
1553            },
1554            "components":{
1555                "callbacks":{
1556                    "Shared":{
1557                        "{$request.body#/sharedUrl}":{
1558                            "post":{
1559                                "operationId":"sharedCb",
1560                                "responses":{"200":{"description":"ok"}}
1561                            }
1562                        }
1563                    }
1564                }
1565            }
1566        }"##;
1567        let ir = parse_str(src).unwrap().spec.unwrap();
1568        let sub = ir.operations.iter().find(|o| o.id == "sub").unwrap();
1569        assert_eq!(sub.callbacks.len(), 2);
1570        let evt = sub.callbacks.iter().find(|c| c.name == "evt").unwrap();
1571        assert_eq!(evt.expression, "{$request.body#/url}");
1572        assert_eq!(evt.operation_ids, vec!["evtCb".to_string()]);
1573        let shared = sub.callbacks.iter().find(|c| c.name == "shared").unwrap();
1574        assert_eq!(shared.expression, "{$request.body#/sharedUrl}");
1575        assert_eq!(shared.operation_ids, vec!["sharedCb".to_string()]);
1576        // Callback operations live in Ir.operations alongside the
1577        // top-level operation.
1578        assert!(ir.operations.iter().any(|o| o.id == "evtCb"));
1579        assert!(ir.operations.iter().any(|o| o.id == "sharedCb"));
1580    }
1581
1582    #[test]
1583    fn callback_op_id_collides_with_top_level_emits_dup_error() {
1584        // Callback operationIds share the global namespace with
1585        // top-level operations per OAS.
1586        let src = r##"{
1587            "openapi":"3.0.3",
1588            "info":{"title":"t","version":"1"},
1589            "paths":{
1590                "/a":{
1591                    "post":{
1592                        "operationId":"foo",
1593                        "responses":{"200":{"description":"ok"}},
1594                        "callbacks":{
1595                            "x":{
1596                                "{$req}":{
1597                                    "post":{
1598                                        "operationId":"foo",
1599                                        "responses":{"200":{"description":"ok"}}
1600                                    }
1601                                }
1602                            }
1603                        }
1604                    }
1605                }
1606            }
1607        }"##;
1608        let out = parse_str(src).unwrap();
1609        assert!(out
1610            .diagnostics
1611            .iter()
1612            .any(|d| d.code == diag::E_DUPLICATE_OPERATION_ID));
1613    }
1614
1615    #[test]
1616    fn response_links_populate_inline_and_via_ref() {
1617        let src = r##"{
1618            "openapi":"3.0.3",
1619            "info":{"title":"t","version":"1"},
1620            "paths":{
1621                "/u":{
1622                    "get":{
1623                        "operationId":"getU",
1624                        "responses":{
1625                            "200":{
1626                                "description":"ok",
1627                                "links":{
1628                                    "addr":{
1629                                        "operationId":"getA",
1630                                        "parameters":{"id":"$response.body#/id"},
1631                                        "description":"docs"
1632                                    },
1633                                    "shared":{"$ref":"#/components/links/Shared"}
1634                                }
1635                            }
1636                        }
1637                    }
1638                },
1639                "/a":{
1640                    "get":{
1641                        "operationId":"getA",
1642                        "responses":{"200":{"description":"ok"}}
1643                    }
1644                }
1645            },
1646            "components":{
1647                "links":{
1648                    "Shared":{"operationId":"getA","description":"shared"}
1649                }
1650            }
1651        }"##;
1652        let ir = parse_str(src).unwrap().spec.unwrap();
1653        let op = ir.operations.iter().find(|o| o.id == "getU").unwrap();
1654        let links = &op.responses[0].links;
1655        assert_eq!(links.len(), 2);
1656        let addr = &links.iter().find(|(k, _)| k == "addr").unwrap().1;
1657        assert_eq!(addr.operation_id.as_deref(), Some("getA"));
1658        assert_eq!(addr.parameters.len(), 1);
1659        assert_eq!(addr.parameters[0].0, "id");
1660        assert_eq!(addr.description.as_deref(), Some("docs"));
1661        let shared = &links.iter().find(|(k, _)| k == "shared").unwrap().1;
1662        assert_eq!(shared.description.as_deref(), Some("shared"));
1663        assert_eq!(shared.operation_id.as_deref(), Some("getA"));
1664    }
1665
1666    #[test]
1667    fn link_with_both_operation_ref_and_id_keeps_ref() {
1668        let src = r##"{
1669            "openapi":"3.0.3",
1670            "info":{"title":"t","version":"1"},
1671            "paths":{
1672                "/u":{
1673                    "get":{
1674                        "operationId":"getU",
1675                        "responses":{
1676                            "200":{
1677                                "description":"ok",
1678                                "links":{
1679                                    "x":{
1680                                        "operationRef":"#/paths/~1a/get",
1681                                        "operationId":"getA"
1682                                    }
1683                                }
1684                            }
1685                        }
1686                    }
1687                }
1688            }
1689        }"##;
1690        let out = parse_str(src).unwrap();
1691        let ir = out.spec.unwrap();
1692        let link = &ir.operations[0].responses[0].links[0].1;
1693        assert!(link.operation_ref.is_some());
1694        assert!(link.operation_id.is_none());
1695        assert!(out
1696            .diagnostics
1697            .iter()
1698            .any(|d| d.code == diag::E_LINK_OP_CONFLICT));
1699    }
1700
1701    #[test]
1702    fn link_compound_parameter_survives_via_value_pool() {
1703        let src = r##"{
1704            "openapi":"3.0.3",
1705            "info":{"title":"t","version":"1"},
1706            "paths":{
1707                "/u":{
1708                    "get":{
1709                        "operationId":"getU",
1710                        "responses":{
1711                            "200":{
1712                                "description":"ok",
1713                                "links":{
1714                                    "x":{
1715                                        "operationId":"foo",
1716                                        "parameters":{"complex":["a","b"]}
1717                                    }
1718                                }
1719                            }
1720                        }
1721                    }
1722                }
1723            }
1724        }"##;
1725        let out = parse_str(src).unwrap();
1726        let ir = out.spec.unwrap();
1727        let link = &ir.operations[0].responses[0].links[0].1;
1728        // Compound parameters survive: the entry resolves to a List in the
1729        // value pool. No W-*-DROPPED warnings any more.
1730        assert_eq!(link.parameters.len(), 1);
1731        let r = link.parameters[0].1 as usize;
1732        assert!(matches!(ir.values[r], forge_ir::Value::List { .. }));
1733    }
1734
1735    #[test]
1736    fn xml_block_populates_with_all_fields() {
1737        let src = r#"{
1738            "openapi":"3.0.3",
1739            "info":{"title":"t","version":"1"},
1740            "paths":{},
1741            "components":{
1742                "schemas":{
1743                    "Pet":{
1744                        "type":"object",
1745                        "xml":{
1746                            "name":"Pet",
1747                            "namespace":"http://example.com/pet",
1748                            "prefix":"pt",
1749                            "attribute":false,
1750                            "wrapped":true,
1751                            "x-vendor":"acme"
1752                        }
1753                    }
1754                }
1755            }
1756        }"#;
1757        let ir = parse_str(src).unwrap().spec.unwrap();
1758        let pet = ir.types.iter().find(|t| t.id == "Pet").unwrap();
1759        let xml = pet.xml.as_ref().expect("xml populated");
1760        assert_eq!(xml.name.as_deref(), Some("Pet"));
1761        assert_eq!(xml.namespace.as_deref(), Some("http://example.com/pet"));
1762        assert_eq!(xml.prefix.as_deref(), Some("pt"));
1763        assert!(!xml.attribute);
1764        assert!(xml.wrapped);
1765        assert_eq!(xml.extensions.len(), 1);
1766        assert_eq!(xml.extensions[0].0, "x-vendor");
1767    }
1768
1769    #[test]
1770    fn xml_attribute_defaults_to_false() {
1771        let src = r#"{
1772            "openapi":"3.0.3",
1773            "info":{"title":"t","version":"1"},
1774            "paths":{},
1775            "components":{
1776                "schemas":{
1777                    "Foo":{"type":"string","xml":{"name":"Foo"}}
1778                }
1779            }
1780        }"#;
1781        let ir = parse_str(src).unwrap().spec.unwrap();
1782        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1783        let xml = foo.xml.as_ref().unwrap();
1784        assert!(!xml.attribute);
1785        assert!(!xml.wrapped);
1786    }
1787
1788    #[test]
1789    fn xml_absent_leaves_field_none() {
1790        let src = r#"{
1791            "openapi":"3.0.3",
1792            "info":{"title":"t","version":"1"},
1793            "paths":{},
1794            "components":{"schemas":{"Foo":{"type":"string"}}}
1795        }"#;
1796        let ir = parse_str(src).unwrap().spec.unwrap();
1797        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1798        assert!(foo.xml.is_none());
1799    }
1800
1801    #[test]
1802    fn examples_populate_at_parameter_and_schema_sites() {
1803        let src = r##"{
1804            "openapi":"3.0.3",
1805            "info":{"title":"t","version":"1"},
1806            "paths":{
1807                "/x/{id}":{
1808                    "get":{
1809                        "operationId":"getX",
1810                        "parameters":[{
1811                            "name":"id","in":"path","required":true,
1812                            "schema":{"type":"string"},
1813                            "examples":{
1814                                "short":{"summary":"S","value":"42"},
1815                                "uuid":{"$ref":"#/components/examples/UuidExample"}
1816                            }
1817                        }],
1818                        "responses":{"204":{"description":"ok"}}
1819                    }
1820                }
1821            },
1822            "components":{
1823                "examples":{
1824                    "UuidExample":{"summary":"UUID","value":"abc"}
1825                },
1826                "schemas":{
1827                    "Foo":{"type":"string","example":"hello"}
1828                }
1829            }
1830        }"##;
1831        let ir = parse_str(src).unwrap().spec.unwrap();
1832        // Parameter examples (inline + ref'd).
1833        let param = &ir.operations[0].path_params[0];
1834        assert_eq!(param.examples.len(), 2);
1835        assert_eq!(param.examples[0].0, "short");
1836        let r0 = param.examples[0].1.value.unwrap() as usize;
1837        assert_eq!(ir.values[r0], forge_ir::Value::s("42"));
1838        assert_eq!(param.examples[1].0, "uuid");
1839        let r1 = param.examples[1].1.value.unwrap() as usize;
1840        assert_eq!(ir.values[r1], forge_ir::Value::s("abc"));
1841        // Schema-level 3.0 single-example lands under "_default".
1842        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1843        assert_eq!(foo.examples.len(), 1);
1844        assert_eq!(foo.examples[0].0, "_default");
1845        let r2 = foo.examples[0].1.value.unwrap() as usize;
1846        assert_eq!(ir.values[r2], forge_ir::Value::s("hello"));
1847    }
1848
1849    #[test]
1850    fn compound_example_survives_via_value_pool() {
1851        let src = r#"{
1852            "openapi":"3.0.3",
1853            "info":{"title":"t","version":"1"},
1854            "paths":{},
1855            "components":{
1856                "schemas":{
1857                    "Foo":{"type":"object","example":{"k":"v"}}
1858                }
1859            }
1860        }"#;
1861        let out = parse_str(src).unwrap();
1862        let ir = out.spec.unwrap();
1863        let foo = ir.types.iter().find(|t| t.id == "Foo").cloned().unwrap();
1864        // Compound example survives in the pool.
1865        assert_eq!(foo.examples.len(), 1);
1866        assert_eq!(foo.examples[0].0, "_default");
1867        let r = foo.examples[0].1.value.unwrap() as usize;
1868        let resolved = &ir.values[r];
1869        let forge_ir::Value::Object { fields } = resolved else {
1870            panic!("expected object example, got {resolved:?}");
1871        };
1872        assert_eq!(fields.len(), 1);
1873        assert_eq!(fields[0].0, "k");
1874        assert_eq!(ir.values[fields[0].1 as usize], forge_ir::Value::s("v"));
1875    }
1876
1877    #[test]
1878    fn example_with_value_and_external_value_keeps_value() {
1879        let src = r##"{
1880            "openapi":"3.0.3",
1881            "info":{"title":"t","version":"1"},
1882            "paths":{},
1883            "components":{
1884                "examples":{
1885                    "Conflict":{
1886                        "value":"inline",
1887                        "externalValue":"https://example.com/blob"
1888                    }
1889                },
1890                "schemas":{
1891                    "Foo":{
1892                        "type":"string",
1893                        "examples":{"a":{"$ref":"#/components/examples/Conflict"}}
1894                    }
1895                }
1896            }
1897        }"##;
1898        let out = parse_str(src).unwrap();
1899        let ir = out.spec.as_ref().unwrap();
1900        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1901        let ex = &foo.examples[0].1;
1902        let r = ex.value.unwrap() as usize;
1903        assert_eq!(ir.values[r], forge_ir::Value::s("inline"));
1904        assert!(ex.external_value.is_none());
1905        assert!(out
1906            .diagnostics
1907            .iter()
1908            .any(|d| d.code == diag::E_EXAMPLE_VALUE_CONFLICT));
1909    }
1910
1911    #[test]
1912    fn item_schema_populates_item_schema_and_type() {
1913        // OAS 3.2: itemSchema-only entry. type is set to the item-type
1914        // ref so non-streaming generators see a usable type;
1915        // item_schema is populated for streaming-aware generators.
1916        let src = r##"{
1917            "openapi":"3.2.0",
1918            "info":{"title":"t","version":"1"},
1919            "paths":{
1920                "/events":{
1921                    "get":{
1922                        "operationId":"stream",
1923                        "responses":{
1924                            "200":{
1925                                "description":"jsonl",
1926                                "content":{
1927                                    "application/jsonl":{
1928                                        "itemSchema":{"$ref":"#/components/schemas/Event"}
1929                                    }
1930                                }
1931                            }
1932                        }
1933                    }
1934                }
1935            },
1936            "components":{
1937                "schemas":{
1938                    "Event":{"type":"object","properties":{"id":{"type":"string"}}}
1939                }
1940            }
1941        }"##;
1942        let ir = parse_str(src).unwrap().spec.unwrap();
1943        let op = &ir.operations[0];
1944        let content = &op.responses[0].content[0];
1945        assert_eq!(content.media_type, "application/jsonl");
1946        assert_eq!(content.r#type, "Event");
1947        assert_eq!(content.item_schema.as_deref(), Some("Event"));
1948    }
1949
1950    #[test]
1951    fn schema_only_leaves_item_schema_none() {
1952        // Plain schema should not populate item_schema.
1953        let src = r#"{
1954            "openapi":"3.0.3",
1955            "info":{"title":"t","version":"1"},
1956            "paths":{
1957                "/x":{
1958                    "get":{
1959                        "operationId":"x",
1960                        "responses":{
1961                            "200":{"description":"ok","content":{
1962                                "application/json":{"schema":{"type":"string"}}
1963                            }}
1964                        }
1965                    }
1966                }
1967            }
1968        }"#;
1969        let ir = parse_str(src).unwrap().spec.unwrap();
1970        let content = &ir.operations[0].responses[0].content[0];
1971        assert!(content.item_schema.is_none());
1972    }
1973
1974    #[test]
1975    fn schema_and_item_schema_together_emit_conflict_error() {
1976        let src = r#"{
1977            "openapi":"3.2.0",
1978            "info":{"title":"t","version":"1"},
1979            "paths":{
1980                "/x":{
1981                    "get":{
1982                        "operationId":"x",
1983                        "responses":{
1984                            "200":{"description":"ok","content":{
1985                                "application/json":{
1986                                    "schema":{"type":"string"},
1987                                    "itemSchema":{"type":"string"}
1988                                }
1989                            }}
1990                        }
1991                    }
1992                }
1993            }
1994        }"#;
1995        let out = parse_str(src).unwrap();
1996        assert!(out
1997            .diagnostics
1998            .iter()
1999            .any(|d| d.code == diag::E_CONTENT_SCHEMA_CONFLICT));
2000    }
2001
2002    #[test]
2003    fn additional_operations_walk_into_other_method() {
2004        let src = r#"{
2005            "openapi":"3.2.0",
2006            "info":{"title":"t","version":"1"},
2007            "paths":{
2008                "/items":{
2009                    "get":{"operationId":"listItems","responses":{"204":{"description":"ok"}}},
2010                    "additionalOperations":{
2011                        "QUERY":{
2012                            "operationId":"queryItems",
2013                            "responses":{"204":{"description":"ok"}}
2014                        }
2015                    }
2016                }
2017            }
2018        }"#;
2019        let ir = parse_str(src).unwrap().spec.unwrap();
2020        let query_op = ir
2021            .operations
2022            .iter()
2023            .find(|o| o.id == "queryItems")
2024            .expect("queryItems present");
2025        assert_eq!(query_op.method, forge_ir::HttpMethod::Other("QUERY".into()));
2026        // Standard method untouched.
2027        let list_op = ir.operations.iter().find(|o| o.id == "listItems").unwrap();
2028        assert_eq!(list_op.method, forge_ir::HttpMethod::Get);
2029    }
2030
2031    #[test]
2032    fn additional_operations_method_normalised_to_uppercase() {
2033        // RFC 7230 §3.1.1 method names are case-sensitive but
2034        // conventionally uppercase. The parser uppercases so generators
2035        // emitting `Method::from_bytes(b"...")` see a single canonical
2036        // form.
2037        let src = r#"{
2038            "openapi":"3.2.0",
2039            "info":{"title":"t","version":"1"},
2040            "paths":{
2041                "/x":{
2042                    "additionalOperations":{
2043                        "Query":{
2044                            "operationId":"qx",
2045                            "responses":{"204":{"description":"ok"}}
2046                        }
2047                    }
2048                }
2049            }
2050        }"#;
2051        let ir = parse_str(src).unwrap().spec.unwrap();
2052        assert_eq!(
2053            ir.operations[0].method,
2054            forge_ir::HttpMethod::Other("QUERY".into())
2055        );
2056    }
2057
2058    #[test]
2059    fn http_method_as_str_returns_wire_form() {
2060        use forge_ir::HttpMethod as M;
2061        assert_eq!(M::Get.as_str(), "GET");
2062        assert_eq!(M::Patch.as_str(), "PATCH");
2063        assert_eq!(M::Other("QUERY".into()).as_str(), "QUERY");
2064    }
2065
2066    #[test]
2067    fn schema_defaults_populate_named_type_and_property() {
2068        let src = r#"{
2069            "openapi":"3.0.3",
2070            "info":{"title":"t","version":"1"},
2071            "paths":{},
2072            "components":{
2073                "schemas":{
2074                    "PageSize":{"type":"integer","default":25},
2075                    "Pet":{
2076                        "type":"object",
2077                        "properties":{
2078                            "name":{"type":"string","default":"Rex"}
2079                        }
2080                    }
2081                }
2082            }
2083        }"#;
2084        let ir = parse_str(src).unwrap().spec.unwrap();
2085        let page_size = ir.types.iter().find(|t| t.id == "PageSize").unwrap();
2086        let r = page_size.default.unwrap() as usize;
2087        assert_eq!(ir.values[r], forge_ir::Value::Int { value: 25 });
2088        let pet = ir.types.iter().find(|t| t.id == "Pet").unwrap();
2089        let forge_ir::TypeDef::Object(pet_obj) = &pet.definition else {
2090            panic!("Pet should be object");
2091        };
2092        let name_prop = pet_obj
2093            .properties
2094            .iter()
2095            .find(|p| p.name == "name")
2096            .unwrap();
2097        let r = name_prop.default.unwrap() as usize;
2098        assert_eq!(ir.values[r], forge_ir::Value::s("Rex"));
2099    }
2100
2101    #[test]
2102    fn schema_default_null_round_trips() {
2103        // JSON `null` is a scalar; round-trips as Value::Null in the pool.
2104        let src = r#"{
2105            "openapi":"3.0.3",
2106            "info":{"title":"t","version":"1"},
2107            "paths":{},
2108            "components":{
2109                "schemas":{
2110                    "Empty":{"type":"string","default":null}
2111                }
2112            }
2113        }"#;
2114        let ir = parse_str(src).unwrap().spec.unwrap();
2115        let empty = ir.types.iter().find(|t| t.id == "Empty").unwrap();
2116        let r = empty.default.unwrap() as usize;
2117        assert_eq!(ir.values[r], forge_ir::Value::Null);
2118    }
2119
2120    #[test]
2121    fn schema_compound_default_survives_via_value_pool() {
2122        let src = r#"{
2123            "openapi":"3.0.3",
2124            "info":{"title":"t","version":"1"},
2125            "paths":{},
2126            "components":{
2127                "schemas":{
2128                    "Cfg":{"type":"object","default":{"k":"v"}}
2129                }
2130            }
2131        }"#;
2132        let out = parse_str(src).unwrap();
2133        let ir = out.spec.unwrap();
2134        let cfg = ir.types.iter().find(|t| t.id == "Cfg").unwrap();
2135        let r = cfg.default.unwrap() as usize;
2136        let forge_ir::Value::Object { fields } = &ir.values[r] else {
2137            panic!("expected object default");
2138        };
2139        assert_eq!(fields.len(), 1);
2140        assert_eq!(fields[0].0, "k");
2141        assert_eq!(ir.values[fields[0].1 as usize], forge_ir::Value::s("v"));
2142    }
2143
2144    #[test]
2145    fn tags_walk_into_structured_records() {
2146        let src = r#"{
2147            "openapi":"3.2.0",
2148            "info":{"title":"t","version":"1"},
2149            "tags":[
2150                {
2151                    "name":"pets",
2152                    "summary":"S",
2153                    "description":"D",
2154                    "kind":"audience",
2155                    "externalDocs":{"url":"https://example.com"}
2156                },
2157                {"name":"cats","parent":"pets"}
2158            ],
2159            "paths":{}
2160        }"#;
2161        let ir = parse_str(src).unwrap().spec.unwrap();
2162        // Sorted by name for determinism — cats before pets.
2163        assert_eq!(ir.tags[0].name, "cats");
2164        assert_eq!(ir.tags[0].parent.as_deref(), Some("pets"));
2165        assert_eq!(ir.tags[1].name, "pets");
2166        assert_eq!(ir.tags[1].summary.as_deref(), Some("S"));
2167        assert_eq!(ir.tags[1].description.as_deref(), Some("D"));
2168        assert_eq!(ir.tags[1].kind.as_deref(), Some("audience"));
2169        assert_eq!(
2170            ir.tags[1].external_docs.as_ref().unwrap().url,
2171            "https://example.com"
2172        );
2173    }
2174
2175    #[test]
2176    fn tag_parent_dangling_drops_ref_keeps_tag() {
2177        let src = r#"{
2178            "openapi":"3.2.0",
2179            "info":{"title":"t","version":"1"},
2180            "tags":[
2181                {"name":"cats","parent":"no-such-tag"}
2182            ],
2183            "paths":{}
2184        }"#;
2185        let out = parse_str(src).unwrap();
2186        let ir = out.spec.unwrap();
2187        assert_eq!(ir.tags.len(), 1);
2188        assert_eq!(ir.tags[0].name, "cats");
2189        // Parent reference dropped; the tag itself survives.
2190        assert!(ir.tags[0].parent.is_none());
2191        assert!(out
2192            .diagnostics
2193            .iter()
2194            .any(|d| d.code == diag::W_TAG_PARENT_DANGLING));
2195    }
2196
2197    #[test]
2198    fn tags_extensions_round_trip() {
2199        let src = r#"{
2200            "openapi":"3.0.3",
2201            "info":{"title":"t","version":"1"},
2202            "tags":[
2203                {"name":"pets","x-priority":5}
2204            ],
2205            "paths":{}
2206        }"#;
2207        let ir = parse_str(src).unwrap().spec.unwrap();
2208        let ext = &ir.tags[0].extensions;
2209        assert_eq!(ext.len(), 1);
2210        assert_eq!(ext[0].0, "x-priority");
2211    }
2212
2213    #[test]
2214    fn operation_servers_resolution_picks_most_specific() {
2215        let src = r#"{
2216            "openapi":"3.0.3",
2217            "info":{"title":"t","version":"1"},
2218            "servers":[{"url":"https://root"}],
2219            "paths":{
2220                "/a":{
2221                    "get":{"operationId":"opA","responses":{"204":{"description":"ok"}}}
2222                },
2223                "/b":{
2224                    "servers":[{"url":"https://path-b"}],
2225                    "get":{"operationId":"opB","responses":{"204":{"description":"ok"}}},
2226                    "post":{
2227                        "operationId":"opC",
2228                        "servers":[{"url":"https://op-c"}],
2229                        "responses":{"204":{"description":"ok"}}
2230                    }
2231                }
2232            }
2233        }"#;
2234        let ir = parse_str(src).unwrap().spec.unwrap();
2235        let by_id = |id: &str| {
2236            ir.operations
2237                .iter()
2238                .find(|o| o.id == id)
2239                .unwrap_or_else(|| panic!("operation {id} not found"))
2240        };
2241        assert_eq!(by_id("opA").servers[0].url, "https://root");
2242        assert_eq!(by_id("opB").servers[0].url, "https://path-b");
2243        assert_eq!(by_id("opC").servers[0].url, "https://op-c");
2244    }
2245
2246    #[test]
2247    fn operation_servers_empty_when_no_root_or_overrides() {
2248        // No `servers` anywhere — operation list stays empty rather than
2249        // synthesising a default URL.
2250        let src = r#"{
2251            "openapi":"3.0.3",
2252            "info":{"title":"t","version":"1"},
2253            "paths":{
2254                "/x":{"get":{"operationId":"x","responses":{"204":{"description":"ok"}}}}
2255            }
2256        }"#;
2257        let ir = parse_str(src).unwrap().spec.unwrap();
2258        assert!(ir.operations[0].servers.is_empty());
2259        assert!(ir.servers.is_empty());
2260    }
2261
2262    #[test]
2263    fn operation_servers_explicit_empty_array_falls_through_to_root() {
2264        // OAS doesn't define semantics for an empty `servers: []` on an
2265        // operation. We treat it as "no override" and inherit the root —
2266        // matches the empty-vs-absent distinction we already do for
2267        // `security`. Document the choice in the test.
2268        let src = r#"{
2269            "openapi":"3.0.3",
2270            "info":{"title":"t","version":"1"},
2271            "servers":[{"url":"https://root"}],
2272            "paths":{
2273                "/x":{"get":{
2274                    "operationId":"x",
2275                    "servers":[],
2276                    "responses":{"204":{"description":"ok"}}
2277                }}
2278            }
2279        }"#;
2280        let ir = parse_str(src).unwrap().spec.unwrap();
2281        assert_eq!(ir.operations[0].servers[0].url, "https://root");
2282    }
2283
2284    #[test]
2285    fn external_docs_absent_leaves_field_none() {
2286        let src = r#"{
2287            "openapi":"3.0.3",
2288            "info":{"title":"t","version":"1"},
2289            "paths":{}
2290        }"#;
2291        let ir = parse_str(src).unwrap().spec.unwrap();
2292        assert!(ir.external_docs.is_none());
2293    }
2294
2295    #[test]
2296    fn info_license_name_only_round_trips() {
2297        // 3.0 specs commonly carry `license.name` with no `identifier`;
2298        // it must not be lost.
2299        let src = r#"{
2300            "openapi":"3.0.0",
2301            "info":{
2302                "title":"t",
2303                "version":"1",
2304                "license":{"name":"MIT"}
2305            },
2306            "paths":{}
2307        }"#;
2308        let ir = parse_str(src).unwrap().spec.unwrap();
2309        assert_eq!(ir.info.license_name.as_deref(), Some("MIT"));
2310        assert!(ir.info.license_url.is_none());
2311        assert!(ir.info.license_identifier.is_none());
2312    }
2313
2314    #[test]
2315    fn extensions_populate_on_every_specification_object() {
2316        use forge_ir::{SecuritySchemeKind, TypeDef};
2317        // Issue #112. One spec exercising every site that gained an
2318        // `extensions` field: info, server, server-variable, schema /
2319        // property, parameter, request body / body content / encoding,
2320        // response, security scheme, oauth2 flow.
2321        let src = r##"{
2322            "openapi":"3.0.3",
2323            "info":{
2324                "title":"t",
2325                "version":"1",
2326                "x-info":"info-ext"
2327            },
2328            "servers":[{
2329                "url":"https://api.example.com/{tier}",
2330                "x-server":"server-ext",
2331                "variables":{
2332                    "tier":{
2333                        "default":"v1",
2334                        "x-var":"var-ext"
2335                    }
2336                }
2337            }],
2338            "paths":{
2339                "/things":{
2340                    "post":{
2341                        "operationId":"create",
2342                        "parameters":[{
2343                            "name":"q",
2344                            "in":"query",
2345                            "schema":{"type":"string"},
2346                            "x-param":"param-ext"
2347                        }],
2348                        "requestBody":{
2349                            "x-body":"body-ext",
2350                            "content":{
2351                                "multipart/form-data":{
2352                                    "x-content":"content-ext",
2353                                    "schema":{"$ref":"#/components/schemas/Thing"},
2354                                    "encoding":{
2355                                        "name":{
2356                                            "contentType":"text/plain",
2357                                            "x-encoding":"encoding-ext"
2358                                        }
2359                                    }
2360                                }
2361                            }
2362                        },
2363                        "responses":{
2364                            "200":{
2365                                "description":"ok",
2366                                "x-response":"response-ext"
2367                            }
2368                        }
2369                    }
2370                }
2371            },
2372            "components":{
2373                "schemas":{
2374                    "Thing":{
2375                        "type":"object",
2376                        "x-schema":"schema-ext",
2377                        "properties":{
2378                            "name":{
2379                                "type":"string",
2380                                "x-prop":"prop-ext"
2381                            }
2382                        }
2383                    }
2384                },
2385                "securitySchemes":{
2386                    "OAuth":{
2387                        "type":"oauth2",
2388                        "x-scheme":"scheme-ext",
2389                        "flows":{
2390                            "authorizationCode":{
2391                                "authorizationUrl":"https://a",
2392                                "tokenUrl":"https://t",
2393                                "scopes":{},
2394                                "x-flow":"flow-ext"
2395                            }
2396                        }
2397                    }
2398                }
2399            }
2400        }"##;
2401        let ir = parse_str(src).unwrap().spec.unwrap();
2402
2403        // info
2404        let exts = &ir.info.extensions;
2405        assert!(
2406            exts.iter().any(|(k, _)| k == "x-info"),
2407            "info.extensions missing x-info: {exts:?}"
2408        );
2409
2410        // server + server-variable
2411        let server = &ir.servers[0];
2412        assert!(server.extensions.iter().any(|(k, _)| k == "x-server"));
2413        let (_var_name, var) = &server.variables[0];
2414        assert!(var.extensions.iter().any(|(k, _)| k == "x-var"));
2415
2416        // schema (NamedType) + property
2417        let thing = ir.types.iter().find(|t| t.id == "Thing").unwrap();
2418        assert!(thing.extensions.iter().any(|(k, _)| k == "x-schema"));
2419        let TypeDef::Object(obj) = &thing.definition else {
2420            panic!("expected object")
2421        };
2422        let name_prop = obj.properties.iter().find(|p| p.name == "name").unwrap();
2423        assert!(name_prop.extensions.iter().any(|(k, _)| k == "x-prop"));
2424
2425        // operation pieces
2426        let op = &ir.operations[0];
2427        let p = &op.query_params[0];
2428        assert!(p.extensions.iter().any(|(k, _)| k == "x-param"));
2429        let body = op.request_body.as_ref().unwrap();
2430        assert!(body.extensions.iter().any(|(k, _)| k == "x-body"));
2431        let content = &body.content[0];
2432        assert!(content.extensions.iter().any(|(k, _)| k == "x-content"));
2433        let (_enc_name, enc) = &content.encoding[0];
2434        assert!(enc.extensions.iter().any(|(k, _)| k == "x-encoding"));
2435        let resp = &op.responses[0];
2436        assert!(resp.extensions.iter().any(|(k, _)| k == "x-response"));
2437
2438        // security scheme + oauth2 flow
2439        let scheme = ir
2440            .security_schemes
2441            .iter()
2442            .find(|s| s.id == "OAuth")
2443            .unwrap();
2444        assert!(scheme.extensions.iter().any(|(k, _)| k == "x-scheme"));
2445        let SecuritySchemeKind::Oauth2(o) = &scheme.kind else {
2446            panic!("expected oauth2");
2447        };
2448        let flow = &o.flows[0];
2449        assert!(flow.extensions.iter().any(|(k, _)| k == "x-flow"));
2450    }
2451
2452    #[test]
2453    fn compound_extensions_survive_via_value_pool() {
2454        // List / object `x-*` values now survive the WIT boundary via the
2455        // value pool (ADR-0007 amendment). The parser interns the array
2456        // into the pool and `info.extensions` references it by `ValueRef`.
2457        let src = r#"{
2458            "openapi":"3.0.3",
2459            "info":{
2460                "title":"t",
2461                "version":"1",
2462                "x-array":[1,2,3]
2463            },
2464            "paths":{}
2465        }"#;
2466        let ir = parse_str(src).unwrap().spec.unwrap();
2467        let entry = ir
2468            .info
2469            .extensions
2470            .iter()
2471            .find(|(k, _)| k == "x-array")
2472            .expect("x-array extension survives");
2473        let r = entry.1 as usize;
2474        let forge_ir::Value::List { items } = &ir.values[r] else {
2475            panic!("expected list, got {:?}", ir.values[r]);
2476        };
2477        assert_eq!(items.len(), 3);
2478    }
2479
2480    #[test]
2481    fn server_name_3_2_round_trips() {
2482        // OAS 3.2 added `Server.name` as a short label distinct from
2483        // `description`. Capture it verbatim.
2484        let src = r#"{
2485            "openapi":"3.2.0",
2486            "info":{"title":"t","version":"1"},
2487            "servers":[
2488                {"url":"https://api.example.com","name":"production"},
2489                {"url":"https://staging.example.com"}
2490            ],
2491            "paths":{}
2492        }"#;
2493        let ir = parse_str(src).unwrap().spec.unwrap();
2494        assert_eq!(ir.servers[0].name.as_deref(), Some("production"));
2495        assert!(ir.servers[1].name.is_none());
2496    }
2497
2498    #[test]
2499    fn parameter_querystring_3_2_routes_to_new_bucket() {
2500        // OAS 3.2 added `in: querystring`. Should land on the new
2501        // `Operation.querystring_params` slot, not the regular
2502        // `query_params`.
2503        let src = r#"{
2504            "openapi":"3.2.0",
2505            "info":{"title":"t","version":"1"},
2506            "paths":{
2507                "/search":{"get":{
2508                    "operationId":"search",
2509                    "parameters":[
2510                        {"name":"raw","in":"querystring","schema":{"type":"string"}}
2511                    ],
2512                    "responses":{"200":{"description":"ok"}}
2513                }}
2514            }
2515        }"#;
2516        let ir = parse_str(src).unwrap().spec.unwrap();
2517        let op = &ir.operations[0];
2518        assert!(op.query_params.is_empty(), "must not land in query_params");
2519        assert_eq!(op.querystring_params.len(), 1);
2520        assert_eq!(op.querystring_params[0].name, "raw");
2521    }
2522
2523    #[test]
2524    fn example_data_value_serialized_value_3_2() {
2525        // OAS 3.2 split `value` into `dataValue` (parsed) and
2526        // `serializedValue` (wire form). Both must round-trip.
2527        let src = r##"{
2528            "openapi":"3.2.0",
2529            "info":{"title":"t","version":"1"},
2530            "paths":{},
2531            "components":{
2532                "schemas":{
2533                    "Thing":{
2534                        "type":"string",
2535                        "examples":[
2536                            {"summary":"alice","dataValue":"alice","serializedValue":"\"alice\""}
2537                        ]
2538                    }
2539                }
2540            }
2541        }"##;
2542        // Use parameter-level examples since schema-level `examples` array (plural)
2543        // is in a different code path; here exercise the parameter / media-type path.
2544        let src2 = r##"{
2545            "openapi":"3.2.0",
2546            "info":{"title":"t","version":"1"},
2547            "paths":{
2548                "/thing":{"post":{
2549                    "operationId":"create",
2550                    "requestBody":{"content":{"application/json":{
2551                        "schema":{"type":"string"},
2552                        "examples":{
2553                            "alice":{"dataValue":"alice","serializedValue":"\"alice\""}
2554                        }
2555                    }}},
2556                    "responses":{"200":{"description":"ok"}}
2557                }}
2558            }
2559        }"##;
2560        let _ = src; // schema-level examples (plural) handled separately; smoke-load
2561        let ir = parse_str(src2).unwrap().spec.unwrap();
2562        let body = ir.operations[0].request_body.as_ref().unwrap();
2563        let example = &body.content[0].examples[0].1;
2564        let r = example.data_value.unwrap() as usize;
2565        assert_eq!(ir.values[r], forge_ir::Value::s("alice"));
2566        assert_eq!(example.serialized_value.as_deref(), Some("\"alice\""));
2567    }
2568
2569    #[test]
2570    fn xml_text_ordered_3_2() {
2571        // OAS 3.2 added `text` and `ordered` flags to the XML Object.
2572        let src = r#"{
2573            "openapi":"3.2.0",
2574            "info":{"title":"t","version":"1"},
2575            "paths":{},
2576            "components":{
2577                "schemas":{
2578                    "Title":{"type":"string","xml":{"text":true}},
2579                    "Steps":{"type":"array","items":{"type":"string"},"xml":{"wrapped":true,"ordered":true}}
2580                }
2581            }
2582        }"#;
2583        let ir = parse_str(src).unwrap().spec.unwrap();
2584        let title = ir.types.iter().find(|t| t.id == "Title").unwrap();
2585        let title_xml = title.xml.as_ref().unwrap();
2586        assert!(title_xml.text);
2587        assert!(!title_xml.ordered);
2588
2589        let steps = ir.types.iter().find(|t| t.id == "Steps").unwrap();
2590        let steps_xml = steps.xml.as_ref().unwrap();
2591        assert!(steps_xml.ordered);
2592        assert!(!steps_xml.text);
2593    }
2594
2595    #[test]
2596    fn mutual_tls_security_scheme_round_trips() {
2597        use forge_ir::SecuritySchemeKind;
2598        let src = r#"{
2599            "openapi":"3.0.3",
2600            "info":{"title":"t","version":"1"},
2601            "paths":{},
2602            "components":{"securitySchemes":{
2603                "mtls":{"type":"mutualTLS","description":"client-cert auth"}
2604            }}
2605        }"#;
2606        let ir = parse_str(src).unwrap().spec.unwrap();
2607        let scheme = &ir.security_schemes[0];
2608        assert_eq!(scheme.id, "mtls");
2609        assert!(matches!(scheme.kind, SecuritySchemeKind::MutualTls));
2610        assert_eq!(scheme.documentation.as_deref(), Some("client-cert auth"));
2611    }
2612
2613    #[test]
2614    fn oauth2_all_four_flows_succeed() {
2615        use forge_ir::{OAuth2FlowKind, SecuritySchemeKind};
2616        let src = r#"{
2617            "openapi":"3.0.3",
2618            "info":{"title":"t","version":"1"},
2619            "paths":{},
2620            "components":{"securitySchemes":{
2621                "auth":{"type":"oauth2","flows":{
2622                    "implicit":{
2623                        "authorizationUrl":"https://a/auth",
2624                        "scopes":{"read":"r"}
2625                    },
2626                    "password":{
2627                        "tokenUrl":"https://a/token",
2628                        "scopes":{"read":"r"}
2629                    },
2630                    "clientCredentials":{
2631                        "tokenUrl":"https://a/token",
2632                        "scopes":{"read":"r"}
2633                    },
2634                    "authorizationCode":{
2635                        "authorizationUrl":"https://a/auth",
2636                        "tokenUrl":"https://a/token",
2637                        "scopes":{"read":"r"}
2638                    }
2639                }}
2640            }}
2641        }"#;
2642        let ir = parse_str(src).unwrap().spec.unwrap();
2643        let scheme = &ir.security_schemes[0];
2644        let SecuritySchemeKind::Oauth2(o) = &scheme.kind else {
2645            panic!("expected oauth2 kind");
2646        };
2647        assert_eq!(o.flows.len(), 4, "all four flows surface");
2648        let kinds: Vec<OAuth2FlowKind> = o.flows.iter().map(|f| f.kind).collect();
2649        assert!(kinds.contains(&OAuth2FlowKind::Implicit));
2650        assert!(kinds.contains(&OAuth2FlowKind::Password));
2651        assert!(kinds.contains(&OAuth2FlowKind::ClientCredentials));
2652        assert!(kinds.contains(&OAuth2FlowKind::AuthorizationCode));
2653    }
2654
2655    #[test]
2656    fn oauth2_missing_required_url_errors() {
2657        // `password` flow requires `tokenUrl`; absence emits
2658        // `parser/E-OAUTH2-MISSING-URL`.
2659        let src = r#"{
2660            "openapi":"3.0.3",
2661            "info":{"title":"t","version":"1"},
2662            "paths":{},
2663            "components":{"securitySchemes":{
2664                "auth":{"type":"oauth2","flows":{
2665                    "password":{"scopes":{"read":"r"}}
2666                }}
2667            }}
2668        }"#;
2669        let out = parse_str(src).unwrap();
2670        assert!(
2671            out.diagnostics
2672                .iter()
2673                .any(|d| d.code == diag::E_OAUTH2_MISSING_URL),
2674            "expected E-OAUTH2-MISSING-URL"
2675        );
2676    }
2677
2678    #[test]
2679    fn content_encoding_keywords_round_trip() {
2680        // JSON Schema 2020-12 / OAS 3.2 contentEncoding +
2681        // contentMediaType + contentSchema land on
2682        // PrimitiveConstraints. contentSchema's body lifts into the
2683        // type pool under a `<owner>_content_schema` id.
2684        use forge_ir::TypeDef;
2685        let src = r#"{
2686            "openapi":"3.2.0",
2687            "info":{"title":"t","version":"1"},
2688            "paths":{},
2689            "components":{"schemas":{
2690                "Avatar":{
2691                    "type":"string",
2692                    "contentEncoding":"base64",
2693                    "contentMediaType":"image/png"
2694                },
2695                "Embedded":{
2696                    "type":"string",
2697                    "contentMediaType":"application/json",
2698                    "contentSchema":{
2699                        "type":"object",
2700                        "properties":{"id":{"type":"string"}}
2701                    }
2702                }
2703            }}
2704        }"#;
2705        let ir = parse_str(src).unwrap().spec.unwrap();
2706
2707        let avatar = ir.types.iter().find(|t| t.id == "Avatar").unwrap();
2708        let TypeDef::Primitive(p) = &avatar.definition else {
2709            panic!("expected primitive");
2710        };
2711        assert_eq!(p.constraints.content_encoding.as_deref(), Some("base64"));
2712        assert_eq!(
2713            p.constraints.content_media_type.as_deref(),
2714            Some("image/png")
2715        );
2716        assert!(p.constraints.content_schema.is_none());
2717
2718        let embedded = ir.types.iter().find(|t| t.id == "Embedded").unwrap();
2719        let TypeDef::Primitive(p) = &embedded.definition else {
2720            panic!("expected primitive");
2721        };
2722        assert_eq!(
2723            p.constraints.content_media_type.as_deref(),
2724            Some("application/json")
2725        );
2726        let cs_ref = p
2727            .constraints
2728            .content_schema
2729            .as_deref()
2730            .expect("content_schema set");
2731        // The decoded payload's type must be reachable in the type pool.
2732        assert!(ir.types.iter().any(|t| t.id == cs_ref));
2733    }
2734
2735    #[test]
2736    fn components_media_types_pool_resolves_refs() {
2737        // OAS 3.2 `components.mediaTypes` — `$ref` from `requestBody.content.<media>`
2738        // and `response.content.<media>` resolves through the pool.
2739        // Unused entries warn with `parser/W-COMPONENT-MEDIA-TYPE-UNUSED`.
2740        let src = r##"{
2741            "openapi":"3.2.0",
2742            "info":{"title":"t","version":"1"},
2743            "paths":{
2744                "/things":{"post":{
2745                    "operationId":"create",
2746                    "requestBody":{"content":{
2747                        "application/json":{"$ref":"#/components/mediaTypes/ThingJson"}
2748                    }},
2749                    "responses":{"204":{"description":"ok"}}
2750                }}
2751            },
2752            "components":{
2753                "schemas":{
2754                    "Thing":{"type":"object","properties":{"id":{"type":"string"}}}
2755                },
2756                "mediaTypes":{
2757                    "ThingJson":{"schema":{"$ref":"#/components/schemas/Thing"}},
2758                    "Unused":{"schema":{"type":"string"}}
2759                }
2760            }
2761        }"##;
2762        let out = parse_str(src).unwrap();
2763        let ir = out.spec.unwrap();
2764        let body = ir.operations[0].request_body.as_ref().unwrap();
2765        // The ref should resolve and the type should point to Thing.
2766        assert_eq!(body.content[0].r#type, "Thing");
2767        // Unused entry warns.
2768        assert!(
2769            out.diagnostics
2770                .iter()
2771                .any(|d| d.code == diag::W_COMPONENT_MEDIA_TYPE_UNUSED
2772                    && d.message.contains("Unused")),
2773            "expected W-COMPONENT-MEDIA-TYPE-UNUSED for `Unused`"
2774        );
2775        // ThingJson was referenced — must NOT warn.
2776        assert!(
2777            !out.diagnostics
2778                .iter()
2779                .any(|d| d.code == diag::W_COMPONENT_MEDIA_TYPE_UNUSED
2780                    && d.message.contains("ThingJson")),
2781            "ThingJson is referenced; should not warn"
2782        );
2783    }
2784
2785    #[test]
2786    fn json_schema_deferred_keywords_warn_not_error() {
2787        // #144: deferred 2020-12 keywords (dependentRequired,
2788        // dependentSchemas, unevaluatedProperties, $dynamicRef,
2789        // $dynamicAnchor) used to error and reject the whole spec.
2790        // Now they warn and the rest of the schema parses.
2791        let src = r#"{
2792            "openapi":"3.1.0",
2793            "info":{"title":"t","version":"1"},
2794            "paths":{},
2795            "components":{"schemas":{
2796                "Bad":{
2797                    "type":"object",
2798                    "dependentRequired":{"a":["b"]},
2799                    "unevaluatedProperties":false,
2800                    "properties":{"id":{"type":"string"}}
2801                }
2802            }}
2803        }"#;
2804        let out = parse_str(src).unwrap();
2805        let ir = out.spec.expect("spec parses despite deferred keywords");
2806        // The schema landed in the type pool — `Bad` exists with its
2807        // declared `id` property.
2808        assert!(ir.types.iter().any(|t| t.id == "Bad"));
2809        // Both deferred keywords surfaced as warnings, not errors.
2810        let warns: Vec<&str> = out
2811            .diagnostics
2812            .iter()
2813            .filter(|d| d.severity == forge_ir::Severity::Warning)
2814            .map(|d| d.code.as_str())
2815            .collect();
2816        assert!(
2817            warns.contains(&diag::W_DEPENDENT_REQUIRED_DROPPED),
2818            "expected W-DEPENDENT-REQUIRED-DROPPED, got {warns:?}"
2819        );
2820        assert!(
2821            warns.contains(&diag::W_UNEVALUATED_PROPERTIES_DROPPED),
2822            "expected W-UNEVALUATED-PROPERTIES-DROPPED, got {warns:?}"
2823        );
2824        // No errors — these used to reject the spec entirely.
2825        let errs: Vec<&str> = out
2826            .diagnostics
2827            .iter()
2828            .filter(|d| d.severity == forge_ir::Severity::Error)
2829            .map(|d| d.code.as_str())
2830            .collect();
2831        assert!(errs.is_empty(), "no errors expected, got {errs:?}");
2832    }
2833
2834    #[test]
2835    fn root_json_schema_dialect_and_self_round_trip() {
2836        // #143, #147. Capture jsonSchemaDialect and $self verbatim.
2837        let src = r##"{
2838            "openapi":"3.2.0",
2839            "$self":"https://example.com/api.json",
2840            "jsonSchemaDialect":"https://json-schema.org/draft/2020-12/schema",
2841            "info":{"title":"t","version":"1"},
2842            "paths":{}
2843        }"##;
2844        let ir = parse_str(src).unwrap().spec.unwrap();
2845        assert_eq!(
2846            ir.json_schema_dialect.as_deref(),
2847            Some("https://json-schema.org/draft/2020-12/schema")
2848        );
2849        assert_eq!(ir.self_url.as_deref(), Some("https://example.com/api.json"));
2850    }
2851
2852    #[test]
2853    fn header_style_explode_round_trip() {
2854        // #145. Header object's serialization fields populate the IR
2855        // even though the spec fixes style to `simple`.
2856        use forge_ir::ParameterStyle;
2857        let src = r#"{
2858            "openapi":"3.0.3",
2859            "info":{"title":"t","version":"1"},
2860            "paths":{"/x":{"get":{
2861                "operationId":"x",
2862                "responses":{"200":{
2863                    "description":"ok",
2864                    "headers":{
2865                        "X-Rate":{
2866                            "schema":{"type":"integer"},
2867                            "style":"simple",
2868                            "explode":true,
2869                            "allowReserved":false,
2870                            "allowEmptyValue":false
2871                        }
2872                    }
2873                }}
2874            }}}
2875        }"#;
2876        let ir = parse_str(src).unwrap().spec.unwrap();
2877        let resp = &ir.operations[0].responses[0];
2878        let (_name, h) = &resp.headers[0];
2879        assert_eq!(h.style, Some(ParameterStyle::Simple));
2880        assert!(h.explode);
2881        assert!(!h.allow_reserved);
2882        assert!(!h.allow_empty_value);
2883    }
2884
2885    #[test]
2886    fn ref_siblings_3_1_plus_merge_onto_target() {
2887        // #146 (covers #139 too): in 3.1+, sibling keywords on a `$ref`
2888        // override the resolved target's same-keyed fields. OAS 3.2
2889        // codifies `summary` / `description` on Reference Object.
2890        let src = r##"{
2891            "openapi":"3.2.0",
2892            "info":{"title":"t","version":"1"},
2893            "paths":{"/x":{"get":{
2894                "operationId":"x",
2895                "responses":{"200":{
2896                    "$ref":"#/components/responses/Shared",
2897                    "description":"per-call override"
2898                }}
2899            }}},
2900            "components":{"responses":{
2901                "Shared":{
2902                    "description":"shared default",
2903                    "content":{"application/json":{"schema":{"type":"string"}}}
2904                }
2905            }}
2906        }"##;
2907        let ir = parse_str(src).unwrap().spec.unwrap();
2908        let resp = &ir.operations[0].responses[0];
2909        assert_eq!(
2910            resp.documentation.as_deref(),
2911            Some("per-call override"),
2912            "the sibling `description` wins over the shared default"
2913        );
2914    }
2915
2916    #[test]
2917    fn primitive_kind_carries_only_jsonschema_type_values() {
2918        // #105: PrimitiveKind is exactly the JSON Schema `type`
2919        // keyword's leaf values. All format refinements land in
2920        // `format_extension` verbatim — including ones the IR used
2921        // to fold into rich kinds (`int32`, `date`, `email`, `byte`).
2922        use forge_ir::{PrimitiveKind, TypeDef};
2923        let src = r#"{
2924            "openapi":"3.0.3",
2925            "info":{"title":"t","version":"1"},
2926            "paths":{},
2927            "components":{"schemas":{
2928                "Plain":      {"type":"string"},
2929                "Stamp":      {"type":"string","format":"date-time"},
2930                "Mail":       {"type":"string","format":"email"},
2931                "Avatar":     {"type":"string","format":"byte"},
2932                "Tally":      {"type":"integer","format":"int32"},
2933                "Big":        {"type":"integer","format":"int64"},
2934                "Money":      {"type":"string","format":"decimal"},
2935                "Flag":       {"type":"boolean"}
2936            }}
2937        }"#;
2938        let ir = parse_str(src).unwrap().spec.unwrap();
2939        let prim = |id: &str| -> (PrimitiveKind, Option<String>) {
2940            let nt = ir.types.iter().find(|t| t.id == id).unwrap();
2941            let TypeDef::Primitive(p) = &nt.definition else {
2942                panic!("{id} not primitive");
2943            };
2944            (p.kind, p.constraints.format_extension.clone())
2945        };
2946        assert_eq!(prim("Plain"), (PrimitiveKind::String, None));
2947        assert_eq!(
2948            prim("Stamp"),
2949            (PrimitiveKind::String, Some("date-time".into()))
2950        );
2951        assert_eq!(prim("Mail"), (PrimitiveKind::String, Some("email".into())));
2952        assert_eq!(prim("Avatar"), (PrimitiveKind::String, Some("byte".into())));
2953        assert_eq!(
2954            prim("Tally"),
2955            (PrimitiveKind::Integer, Some("int32".into()))
2956        );
2957        assert_eq!(prim("Big"), (PrimitiveKind::Integer, Some("int64".into())));
2958        assert_eq!(
2959            prim("Money"),
2960            (PrimitiveKind::String, Some("decimal".into()))
2961        );
2962        assert_eq!(prim("Flag"), (PrimitiveKind::Bool, None));
2963    }
2964}