1#![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
68pub fn parse_str(source: &str) -> Result<ParseOutput, ParseError> {
70 parse_str_with_file(source, None)
71}
72
73pub 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
83pub 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 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 ctx.doc_roots.insert(ctx.current_doc.clone(), root.clone());
130 let mut ptr = Ptr::new();
131
132 if !check_version(&mut ctx, root_map, &mut ptr) {
134 return Ok(ParseOutput {
136 spec: None,
137 diagnostics: ctx.diagnostics,
138 });
139 }
140
141 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 security::walk_components(&mut ctx, root_map, &mut ptr);
149
150 register_component_schemas(&mut ctx, root_map);
154 walk_component_schemas(&mut ctx, root_map, &mut ptr);
155
156 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 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 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 let root_external_docs = parse_external_docs(&mut ctx, root_map.get("externalDocs"), &mut ptr);
183
184 scan_unused_component_path_items(&mut ctx, root_map, &mut ptr);
189 scan_unused_component_media_types(&mut ctx, root_map, &mut ptr);
190
191 let json_schema_dialect = root_map
194 .get("jsonSchemaDialect")
195 .and_then(J::as_str)
196 .map(String::from);
197 let self_url = root_map.get("$self").and_then(J::as_str).map(String::from);
198
199 let mut ir = Ir {
201 info: ctx.info.take().unwrap_or(ApiInfo {
202 title: String::new(),
203 version: String::new(),
204 summary: None,
205 description: None,
206 terms_of_service: None,
207 contact: None,
208 license_name: None,
209 license_url: None,
210 license_identifier: None,
211 extensions: vec![],
212 }),
213 operations: std::mem::take(&mut ctx.operations),
214 types: ctx.types.values().cloned().collect::<Vec<_>>(),
215 security_schemes: std::mem::take(&mut ctx.security_schemes),
216 servers: std::mem::take(&mut ctx.servers),
217 webhooks: std::mem::take(&mut ctx.webhooks),
218 external_docs: root_external_docs,
219 tags,
220 json_schema_dialect,
221 self_url,
222 values: std::mem::take(&mut ctx.values).finish(),
223 };
224 let mut diagnostics = std::mem::take(&mut ctx.diagnostics);
225 diagnostics.extend(finalize::canonicalize(&mut ir));
226
227 Ok(ParseOutput {
228 spec: Some(ir),
229 diagnostics,
230 })
231}
232
233fn parse_tags(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) -> Vec<Tag> {
239 let Some(J::Array(tags)) = root.get("tags") else {
240 return Vec::new();
241 };
242 let mut out: Vec<Tag> = Vec::new();
243 let mut declared_names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
244 ptr.with_token("tags", |ptr| {
245 for tag in tags.iter() {
248 if let Some(name) = tag
249 .as_object()
250 .and_then(|m| m.get("name"))
251 .and_then(J::as_str)
252 {
253 declared_names.insert(name.to_string());
254 }
255 }
256 for (i, tag) in tags.iter().enumerate() {
257 ptr.with_index(i, |ptr| {
258 let Some(map) = tag.as_object() else {
259 ctx.push_diag(diag::err(
260 diag::E_INVALID_TYPE,
261 "tag must be an object",
262 ptr.loc(ctx.file),
263 ));
264 return;
265 };
266 let Some(name) = map.get("name").and_then(J::as_str) else {
267 ctx.push_diag(diag::err(
268 diag::E_MISSING_FIELD,
269 "tag is missing required `name`",
270 ptr.loc(ctx.file),
271 ));
272 return;
273 };
274 let summary = crate::schema::summary(map);
277 let description = crate::schema::description(map);
278 let external_docs = parse_external_docs(ctx, map.get("externalDocs"), ptr);
279 let kind = map.get("kind").and_then(J::as_str).map(String::from);
280 let parent_raw = map.get("parent").and_then(J::as_str).map(String::from);
281 let parent = match parent_raw {
282 Some(p) if !declared_names.contains(&p) => {
283 ctx.push_diag(diag::warn(
284 diag::W_TAG_PARENT_DANGLING,
285 format!(
286 "tag `{name}` references parent `{p}`, which is not declared in \
287 the top-level `tags` array; dropping the parent reference."
288 ),
289 ptr.loc(ctx.file),
290 ));
291 None
292 }
293 other => other,
294 };
295 let extensions = operations::collect_extensions(ctx, map, ptr);
296 out.push(Tag {
297 name: name.to_string(),
298 summary,
299 description,
300 external_docs,
301 parent,
302 kind,
303 extensions,
304 });
305 });
306 }
307 });
308 out.sort_by(|a, b| a.name.cmp(&b.name));
310 out
311}
312
313const ACCEPTED_VERSION_PREFIXES: &[&str] = &["3.0.", "3.1.", "3.2."];
317
318pub(crate) fn parse_examples(
331 ctx: &mut Ctx,
332 map: &serde_json::Map<String, J>,
333 ptr: &mut Ptr,
334) -> Vec<(String, Example)> {
335 let mut out = Vec::new();
336 if let Some(raw) = map.get("example") {
338 ptr.with_token("example", |_ptr| {
339 let value = Some(ctx.values.intern_json(raw));
340 out.push((
341 "_default".to_string(),
342 Example {
343 summary: None,
344 description: None,
345 value,
346 external_value: None,
347 data_value: None,
348 serialized_value: None,
349 },
350 ));
351 });
352 }
353 if let Some(J::Object(named)) = map.get("examples") {
355 ptr.with_token("examples", |ptr| {
356 for (name, entry) in named {
357 ptr.with_token(name, |ptr| {
358 crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
359 let Some(emap) = resolved.as_object() else {
360 ctx.push_diag(diag::err(
361 diag::E_INVALID_TYPE,
362 "example must be an object",
363 ptr.loc(ctx.file),
364 ));
365 return Some(());
366 };
367 let summary = emap.get("summary").and_then(J::as_str).map(String::from);
368 let description = emap
369 .get("description")
370 .and_then(J::as_str)
371 .map(String::from);
372 let external_value = emap
373 .get("externalValue")
374 .and_then(J::as_str)
375 .map(String::from);
376 let value = emap.get("value").map(|raw| ctx.values.intern_json(raw));
377 let data_value =
382 emap.get("dataValue").map(|raw| ctx.values.intern_json(raw));
383 let serialized_value = emap
384 .get("serializedValue")
385 .and_then(J::as_str)
386 .map(String::from);
387 if value.is_some() && external_value.is_some() {
388 ctx.push_diag(diag::err(
389 diag::E_EXAMPLE_VALUE_CONFLICT,
390 format!(
391 "example `{name}` declares both `value` and `externalValue`; \
392 OAS §4.7.20 makes them mutually exclusive. Keeping `value`."
393 ),
394 ptr.loc(ctx.file),
395 ));
396 }
397 let kept_external = if value.is_some() {
398 None
399 } else {
400 external_value
401 };
402 out.push((
403 name.clone(),
404 Example {
405 summary,
406 description,
407 value,
408 external_value: kept_external,
409 data_value,
410 serialized_value,
411 },
412 ));
413 Some(())
414 });
415 });
416 }
417 });
418 }
419 if let Some(J::Array(items)) = map.get("examples") {
427 ptr.with_token("examples", |ptr| {
428 for (i, raw) in items.iter().enumerate() {
429 ptr.with_index(i, |_ptr| {
430 let value = Some(ctx.values.intern_json(raw));
431 out.push((
432 format!("_examples[{i}]"),
433 Example {
434 summary: None,
435 description: None,
436 value,
437 external_value: None,
438 data_value: None,
439 serialized_value: None,
440 },
441 ));
442 });
443 }
444 });
445 }
446 out
447}
448
449fn scan_unused_component_path_items(
456 ctx: &mut Ctx,
457 root: &serde_json::Map<String, J>,
458 ptr: &mut Ptr,
459) {
460 let Some(J::Object(components)) = root.get("components") else {
461 return;
462 };
463 let Some(J::Object(path_items)) = components.get("pathItems") else {
464 return;
465 };
466 ptr.with_token("components", |ptr| {
467 ptr.with_token("pathItems", |ptr| {
468 for name in path_items.keys() {
469 if !ctx.referenced_component_path_items.contains(name) {
470 ptr.with_token(name, |ptr| {
471 ctx.push_diag(diag::warn(
472 diag::W_COMPONENT_PATH_ITEM_UNUSED,
473 format!(
474 "components.pathItems.`{name}` is declared but never \
475 referenced from paths, webhooks, or callbacks. The \
476 declaration is silently invisible to generators."
477 ),
478 ptr.loc(ctx.file),
479 ));
480 });
481 }
482 }
483 });
484 });
485}
486
487fn scan_unused_component_media_types(
494 ctx: &mut Ctx,
495 root: &serde_json::Map<String, J>,
496 ptr: &mut Ptr,
497) {
498 let Some(J::Object(components)) = root.get("components") else {
499 return;
500 };
501 let Some(J::Object(media_types)) = components.get("mediaTypes") else {
502 return;
503 };
504 ptr.with_token("components", |ptr| {
505 ptr.with_token("mediaTypes", |ptr| {
506 for name in media_types.keys() {
507 if !ctx.referenced_component_media_types.contains(name) {
508 ptr.with_token(name, |ptr| {
509 ctx.push_diag(diag::warn(
510 diag::W_COMPONENT_MEDIA_TYPE_UNUSED,
511 format!(
512 "components.mediaTypes.`{name}` is declared but never \
513 referenced. The declaration is silently invisible to \
514 generators."
515 ),
516 ptr.loc(ctx.file),
517 ));
518 });
519 }
520 }
521 });
522 });
523}
524
525pub(crate) fn parse_callbacks(
533 ctx: &mut Ctx,
534 value: Option<&J>,
535 ptr: &mut Ptr,
536 seen_op_ids: &mut std::collections::HashSet<String>,
537) -> Vec<Callback> {
538 let Some(J::Object(named)) = value else {
539 return Vec::new();
540 };
541 let mut out = Vec::new();
542 ptr.with_token("callbacks", |ptr| {
543 for (name, entry) in named {
544 ptr.with_token(name, |ptr| {
545 crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
546 let Some(emap) = resolved.as_object() else {
547 ctx.push_diag(diag::err(
548 diag::E_INVALID_TYPE,
549 "callback must be an object",
550 ptr.loc(ctx.file),
551 ));
552 return Some(());
553 };
554 let extensions = operations::collect_extensions(ctx, emap, ptr);
556 for (expr, path_item) in emap {
557 if expr.starts_with("x-") {
560 continue;
561 }
562 ptr.with_token(expr, |ptr| {
563 let ops =
564 operations::parse_path_item(ctx, expr, path_item, ptr, seen_op_ids);
565 let operation_ids: Vec<String> =
570 ops.iter().map(|o| o.id.clone()).collect();
571 ctx.operations.extend(ops);
572 out.push(Callback {
573 name: name.clone(),
574 expression: expr.clone(),
575 operation_ids,
576 extensions: extensions.clone(),
577 });
578 });
579 }
580 Some(())
581 });
582 });
583 }
584 });
585 out
586}
587
588pub(crate) fn parse_links(ctx: &mut Ctx, value: Option<&J>, ptr: &mut Ptr) -> Vec<(String, Link)> {
594 let Some(J::Object(named)) = value else {
595 return Vec::new();
596 };
597 let mut out = Vec::new();
598 ptr.with_token("links", |ptr| {
599 for (name, entry) in named {
600 ptr.with_token(name, |ptr| {
601 crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
602 let Some(lmap) = resolved.as_object() else {
603 ctx.push_diag(diag::err(
604 diag::E_INVALID_TYPE,
605 "link must be an object",
606 ptr.loc(ctx.file),
607 ));
608 return Some(());
609 };
610 let operation_ref = lmap
611 .get("operationRef")
612 .and_then(J::as_str)
613 .map(String::from);
614 let raw_operation_id = lmap
615 .get("operationId")
616 .and_then(J::as_str)
617 .map(String::from);
618 let operation_id = if operation_ref.is_some() && raw_operation_id.is_some() {
619 ctx.push_diag(diag::err(
620 diag::E_LINK_OP_CONFLICT,
621 format!(
622 "link `{name}` declares both `operationRef` and `operationId`; \
623 OAS §4.7.21 makes them mutually exclusive. Keeping `operationRef`."
624 ),
625 ptr.loc(ctx.file),
626 ));
627 None
628 } else {
629 raw_operation_id
630 };
631 let parameters = lmap
632 .get("parameters")
633 .and_then(|v| v.as_object())
634 .map(|m| {
635 m.iter()
636 .map(|(k, raw)| (k.clone(), ctx.values.intern_json(raw)))
637 .collect()
638 })
639 .unwrap_or_default();
640 let request_body = lmap
641 .get("requestBody")
642 .map(|raw| ctx.values.intern_json(raw));
643 let description = crate::schema::description(lmap);
645 let server = lmap.get("server").and_then(|s| {
646 s.as_object().and_then(|m| {
647 let url = m.get("url").and_then(J::as_str)?;
648 let server_desc =
649 m.get("description").and_then(J::as_str).map(String::from);
650 let server_name = m.get("name").and_then(J::as_str).map(String::from);
651 Some(Server {
652 url: url.to_string(),
653 description: server_desc,
654 name: server_name,
655 variables: Vec::new(),
656 extensions: Vec::new(),
657 })
658 })
659 });
660 let extensions = operations::collect_extensions(ctx, lmap, ptr);
661 out.push((
662 name.clone(),
663 Link {
664 operation_ref,
665 operation_id,
666 parameters,
667 request_body,
668 description,
669 server,
670 extensions,
671 },
672 ));
673 Some(())
674 });
675 });
676 }
677 });
678 out
679}
680
681pub(crate) fn parse_xml(
686 ctx: &mut Ctx,
687 map: &serde_json::Map<String, J>,
688 ptr: &mut Ptr,
689) -> Option<XmlObject> {
690 let xml = map.get("xml")?;
691 let xml_map = xml.as_object()?;
692 let mut out = None;
693 ptr.with_token("xml", |ptr| {
694 let name = xml_map.get("name").and_then(J::as_str).map(String::from);
695 let namespace = xml_map
696 .get("namespace")
697 .and_then(J::as_str)
698 .map(String::from);
699 let prefix = xml_map.get("prefix").and_then(J::as_str).map(String::from);
700 let attribute = xml_map
701 .get("attribute")
702 .and_then(J::as_bool)
703 .unwrap_or(false);
704 let wrapped = xml_map.get("wrapped").and_then(J::as_bool).unwrap_or(false);
705 let text = xml_map.get("text").and_then(J::as_bool).unwrap_or(false);
706 let ordered = xml_map.get("ordered").and_then(J::as_bool).unwrap_or(false);
707 let extensions = operations::collect_extensions(ctx, xml_map, ptr);
708 out = Some(XmlObject {
709 name,
710 namespace,
711 prefix,
712 attribute,
713 wrapped,
714 text,
715 ordered,
716 extensions,
717 });
718 });
719 out
720}
721
722pub(crate) fn parse_default(
726 ctx: &mut Ctx,
727 map: &serde_json::Map<String, J>,
728 _ptr: &mut Ptr,
729 _site: &str,
730) -> Option<forge_ir::ValueRef> {
731 let raw = map.get("default")?;
732 Some(ctx.values.intern_json(raw))
733}
734
735pub(crate) fn parse_external_docs(
740 ctx: &mut Ctx,
741 value: Option<&J>,
742 ptr: &mut Ptr,
743) -> Option<ExternalDocs> {
744 let map = value?.as_object()?;
745 let mut out = None;
746 ptr.with_token("externalDocs", |ptr| {
747 let Some(url) = map.get("url").and_then(J::as_str) else {
748 ctx.push_diag(diag::warn(
749 diag::W_EXTERNAL_DOCS_NO_URL,
750 "externalDocs is missing required `url`; dropping the block.",
751 ptr.loc(ctx.file),
752 ));
753 return;
754 };
755 let description = map.get("description").and_then(J::as_str).map(String::from);
756 out = Some(ExternalDocs {
757 description,
758 url: url.to_string(),
759 });
760 });
761 out
762}
763
764fn check_version(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) -> bool {
765 if root.contains_key("swagger") {
768 ptr.with_token("swagger", |ptr| {
769 ctx.push_diag(diag::err(
770 diag::E_UNSUPPORTED_VERSION,
771 "OpenAPI 2.0 (Swagger) is not supported and is not on the roadmap. \
772 Convert to OpenAPI 3.0 upstream (e.g. `swagger2openapi`) before invoking forge.",
773 ptr.loc(ctx.file),
774 ));
775 });
776 return false;
777 }
778 let version = root.get("openapi").and_then(J::as_str);
779 match version {
780 Some(v) if ACCEPTED_VERSION_PREFIXES.iter().any(|p| v.starts_with(p)) => {
781 ctx.is_oas_3_0 = v.starts_with("3.0.");
785 true
786 }
787 Some(other) => {
788 let msg = if other.starts_with("2.") || other.starts_with("1.") {
789 format!(
790 "OpenAPI {other} is not supported and is not on the roadmap. \
791 Convert to OpenAPI 3.x upstream before invoking forge."
792 )
793 } else {
794 format!("unsupported OpenAPI version `{other}`; expected 3.0.x / 3.1.x / 3.2.x")
795 };
796 ptr.with_token("openapi", |ptr| {
797 ctx.push_diag(diag::err(
798 diag::E_UNSUPPORTED_VERSION,
799 msg,
800 ptr.loc(ctx.file),
801 ));
802 });
803 false
804 }
805 None => {
806 ctx.push_diag(diag::err(
807 diag::E_MISSING_FIELD,
808 "missing required `openapi` field",
809 SpecLocation::new(""),
810 ));
811 false
812 }
813 }
814}
815
816fn parse_info(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
817 let Some(J::Object(info)) = root.get("info") else {
818 ctx.push_diag(diag::err(
819 diag::E_MISSING_FIELD,
820 "missing required `info` object",
821 ptr.loc(ctx.file),
822 ));
823 return;
824 };
825 ptr.with_token("info", |ptr| {
826 let title = info.get("title").and_then(J::as_str).unwrap_or_else(|| {
827 ctx.push_diag(diag::err(
828 diag::E_MISSING_FIELD,
829 "info is missing `title`",
830 ptr.loc(ctx.file),
831 ));
832 ""
833 });
834 let version = info.get("version").and_then(J::as_str).unwrap_or_else(|| {
835 ctx.push_diag(diag::err(
836 diag::E_MISSING_FIELD,
837 "info is missing `version`",
838 ptr.loc(ctx.file),
839 ));
840 ""
841 });
842 let summary = crate::schema::summary(info);
844 let description = crate::schema::description(info);
845 let terms_of_service = info
846 .get("termsOfService")
847 .and_then(J::as_str)
848 .map(String::from);
849 let contact =
850 info.get("contact")
851 .and_then(|v| v.as_object())
852 .and_then(|m| -> Option<Contact> {
853 let name = m.get("name").and_then(J::as_str).map(String::from);
854 let url = m.get("url").and_then(J::as_str).map(String::from);
855 let email = m.get("email").and_then(J::as_str).map(String::from);
856 if name.is_none() && url.is_none() && email.is_none() {
857 None
858 } else {
859 Some(Contact { name, url, email })
860 }
861 });
862 let license = info.get("license").and_then(|l| l.as_object());
863 let license_name = license
864 .and_then(|m| m.get("name"))
865 .and_then(J::as_str)
866 .map(String::from);
867 let license_url = license
868 .and_then(|m| m.get("url"))
869 .and_then(J::as_str)
870 .map(String::from);
871 let license_identifier = license
872 .and_then(|m| m.get("identifier"))
873 .and_then(J::as_str)
874 .map(String::from);
875 let extensions = operations::collect_extensions(ctx, info, ptr);
876 ctx.info = Some(ApiInfo {
877 title: title.to_string(),
878 version: version.to_string(),
879 summary,
880 description,
881 terms_of_service,
882 contact,
883 license_name,
884 license_url,
885 license_identifier,
886 extensions,
887 });
888 });
889}
890
891fn parse_servers(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
892 let servers = parse_servers_array(ctx, root.get("servers"), ptr);
893 ctx.servers.extend(servers);
894}
895
896pub(crate) fn parse_servers_array(ctx: &mut Ctx, value: Option<&J>, ptr: &mut Ptr) -> Vec<Server> {
901 let Some(J::Array(items)) = value else {
902 return Vec::new();
903 };
904 let mut out = Vec::new();
905 ptr.with_token("servers", |ptr| {
906 for (i, item) in items.iter().enumerate() {
907 ptr.with_index(i, |ptr| {
908 let Some(map) = item.as_object() else {
909 ctx.push_diag(diag::err(
910 diag::E_INVALID_TYPE,
911 "server must be an object",
912 ptr.loc(ctx.file),
913 ));
914 return;
915 };
916 let Some(url) = map.get("url").and_then(J::as_str) else {
917 ctx.push_diag(diag::err(
918 diag::E_MISSING_FIELD,
919 "server is missing `url`",
920 ptr.loc(ctx.file),
921 ));
922 return;
923 };
924 let description = crate::schema::description(map);
926 let server_name = map.get("name").and_then(J::as_str).map(String::from);
927 let mut variables: Vec<(String, ServerVariable)> = Vec::new();
928 if let Some(J::Object(vars)) = map.get("variables") {
929 ptr.with_token("variables", |ptr| {
930 for (name, v) in vars {
931 ptr.with_token(name, |ptr| {
932 let Some(vmap) = v.as_object() else { return };
933 let Some(default) = vmap.get("default").and_then(J::as_str) else {
934 return;
935 };
936 let var_extensions = operations::collect_extensions(ctx, vmap, ptr);
937 let var_description = crate::schema::description(vmap);
939 variables.push((
940 name.clone(),
941 ServerVariable {
942 default: default.to_string(),
943 r#enum: vmap.get("enum").and_then(|e| {
944 e.as_array().map(|arr| {
945 arr.iter()
946 .filter_map(|v| v.as_str().map(String::from))
947 .collect()
948 })
949 }),
950 description: var_description,
951 extensions: var_extensions,
952 },
953 ));
954 });
955 }
956 });
957 }
958 let extensions = operations::collect_extensions(ctx, map, ptr);
959 out.push(Server {
960 url: url.to_string(),
961 description,
962 name: server_name,
963 variables,
964 extensions,
965 });
966 });
967 }
968 });
969 out
970}
971
972fn register_component_schemas(ctx: &mut Ctx, root: &serde_json::Map<String, J>) {
973 let Some(J::Object(components)) = root.get("components") else {
974 return;
975 };
976 let Some(J::Object(schemas)) = components.get("schemas") else {
977 return;
978 };
979 for name in schemas.keys() {
980 let id = sanitize::ident(name);
981 ctx.refs_mut().register(&id);
982 }
983}
984
985fn walk_component_schemas(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
986 let Some(J::Object(components)) = root.get("components") else {
987 return;
988 };
989 let Some(J::Object(schemas)) = components.get("schemas") else {
990 return;
991 };
992 pre_register_external_named_hints(ctx, schemas);
999 let order = order_components_by_allof(schemas);
1000 ptr.with_token("components", |ptr| {
1001 ptr.with_token("schemas", |ptr| {
1002 for name in &order {
1003 let Some(schema) = schemas.get(name) else {
1004 continue;
1005 };
1006 ptr.with_token(name, |ptr| {
1007 let key = (
1012 ctx.current_doc.clone(),
1013 format!("/components/schemas/{name}"),
1014 );
1015 ctx.walking.insert(key.clone());
1016 let _ = parse_schema(ctx, schema, ptr, NameHint::Named(name.clone()));
1017 ctx.walking.remove(&key);
1018 });
1019 }
1020 });
1021 });
1022}
1023
1024fn pre_register_external_named_hints(ctx: &mut Ctx, schemas: &serde_json::Map<String, J>) {
1034 let current_doc = ctx.current_doc.clone();
1035 for (name, schema) in schemas {
1036 let Some(map) = schema.as_object() else {
1037 continue;
1038 };
1039 let Some(J::String(raw)) = map.get("$ref") else {
1040 continue;
1041 };
1042 let (file_part, fragment) = crate::external::split_ref(raw);
1043 if file_part.is_empty() || crate::external::is_url(file_part) {
1044 continue;
1045 }
1046 let Ok(loaded) = ctx.resolver.load(raw, ¤t_doc) else {
1047 continue;
1048 };
1049 let canonical = loaded.canonical_path.clone();
1050 crate::schema::ensure_doc_registered(ctx, &canonical, &loaded.root);
1051 ctx.external_ref_to_id
1052 .entry((canonical, fragment.to_string()))
1053 .or_insert_with(|| crate::sanitize::ident(name));
1054 }
1055}
1056
1057fn order_components_by_allof(schemas: &serde_json::Map<String, J>) -> Vec<String> {
1062 use std::collections::{BTreeMap, BTreeSet};
1063
1064 let mut deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1065 for (name, schema) in schemas {
1066 let mut targets: BTreeSet<String> = BTreeSet::new();
1067 collect_allof_ref_targets(schema, &mut targets);
1068 targets.retain(|t| schemas.contains_key(t) && t != name);
1069 deps.insert(name.clone(), targets);
1070 }
1071
1072 let mut visited: BTreeSet<String> = BTreeSet::new();
1073 let mut ordered: Vec<String> = Vec::new();
1074 let mut all_names: Vec<String> = schemas.keys().cloned().collect();
1075 all_names.sort();
1076
1077 loop {
1078 let next = all_names.iter().find(|n| {
1079 !visited.contains(*n)
1080 && deps
1081 .get(*n)
1082 .map(|d| d.iter().all(|t| visited.contains(t)))
1083 .unwrap_or(true)
1084 });
1085 match next {
1086 Some(name) => {
1087 let n = name.clone();
1088 visited.insert(n.clone());
1089 ordered.push(n);
1090 }
1091 None => break,
1092 }
1093 }
1094 for n in all_names {
1096 if !visited.contains(&n) {
1097 ordered.push(n);
1098 }
1099 }
1100 ordered
1101}
1102
1103fn collect_allof_ref_targets(value: &J, out: &mut std::collections::BTreeSet<String>) {
1104 let Some(map) = value.as_object() else {
1105 return;
1106 };
1107 if let Some(J::Array(parts)) = map.get("allOf") {
1108 for part in parts {
1109 if let Some(rs) = part
1110 .as_object()
1111 .and_then(|m| m.get("$ref"))
1112 .and_then(|r| r.as_str())
1113 {
1114 if let Some(name) = rs.strip_prefix("#/components/schemas/") {
1115 out.insert(name.to_string());
1116 }
1117 }
1118 }
1119 }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125
1126 #[test]
1127 fn empty_input_errors() {
1128 let err = parse_str("").unwrap_err();
1129 matches!(err, ParseError::Empty);
1130 }
1131
1132 #[test]
1133 fn invalid_json_errors() {
1134 let err = parse_str("{not json").unwrap_err();
1135 matches!(err, ParseError::InvalidJson(_));
1136 }
1137
1138 #[test]
1139 fn root_array_errors() {
1140 let err = parse_str("[]").unwrap_err();
1141 matches!(err, ParseError::NotObject);
1142 }
1143
1144 #[test]
1145 fn unsupported_version_diagnostic() {
1146 let src = r#"{"openapi":"4.0.0","info":{"title":"x","version":"1"},"paths":{}}"#;
1148 let out = parse_str(src).unwrap();
1149 assert!(out.spec.is_none());
1150 assert_eq!(out.diagnostics.len(), 1);
1151 assert_eq!(out.diagnostics[0].code, diag::E_UNSUPPORTED_VERSION);
1152 }
1153
1154 #[test]
1155 fn minimal_spec_round_trips() {
1156 let src = r#"{
1157 "openapi":"3.0.3",
1158 "info":{"title":"t","version":"1"},
1159 "paths":{}
1160 }"#;
1161 let out = parse_str(src).unwrap();
1162 let ir = out.spec.unwrap();
1163 assert_eq!(ir.info.title, "t");
1164 assert!(ir.operations.is_empty());
1165 assert!(ir.types.is_empty());
1166 }
1167
1168 #[test]
1169 fn info_full_block_populates_every_field() {
1170 let src = r#"{
1171 "openapi":"3.1.0",
1172 "info":{
1173 "title":"t",
1174 "version":"1",
1175 "summary":"s",
1176 "description":"d",
1177 "termsOfService":"https://tos.example",
1178 "contact":{
1179 "name":"API Team",
1180 "url":"https://example.com",
1181 "email":"team@example.com"
1182 },
1183 "license":{
1184 "name":"Apache 2.0",
1185 "url":"https://www.apache.org/licenses/LICENSE-2.0",
1186 "identifier":"Apache-2.0"
1187 }
1188 },
1189 "paths":{}
1190 }"#;
1191 let ir = parse_str(src).unwrap().spec.unwrap();
1192 assert_eq!(ir.info.summary.as_deref(), Some("s"));
1193 assert_eq!(ir.info.description.as_deref(), Some("d"));
1194 assert_eq!(
1195 ir.info.terms_of_service.as_deref(),
1196 Some("https://tos.example")
1197 );
1198 let contact = ir.info.contact.expect("contact populated");
1199 assert_eq!(contact.name.as_deref(), Some("API Team"));
1200 assert_eq!(contact.url.as_deref(), Some("https://example.com"));
1201 assert_eq!(contact.email.as_deref(), Some("team@example.com"));
1202 assert_eq!(ir.info.license_name.as_deref(), Some("Apache 2.0"));
1203 assert_eq!(
1204 ir.info.license_url.as_deref(),
1205 Some("https://www.apache.org/licenses/LICENSE-2.0")
1206 );
1207 assert_eq!(ir.info.license_identifier.as_deref(), Some("Apache-2.0"));
1208 }
1209
1210 #[test]
1211 fn info_contact_object_with_no_known_keys_is_none() {
1212 let src = r#"{
1216 "openapi":"3.0.0",
1217 "info":{
1218 "title":"t",
1219 "version":"1",
1220 "contact":{ "x-vendor": "acme" }
1221 },
1222 "paths":{}
1223 }"#;
1224 let ir = parse_str(src).unwrap().spec.unwrap();
1225 assert!(ir.info.contact.is_none());
1226 }
1227
1228 #[test]
1229 fn external_docs_populated_at_root_operation_and_schema() {
1230 let src = r#"{
1231 "openapi":"3.0.3",
1232 "info":{"title":"t","version":"1"},
1233 "externalDocs":{"description":"top","url":"https://example.com"},
1234 "paths":{
1235 "/x":{
1236 "get":{
1237 "operationId":"getX",
1238 "externalDocs":{"url":"https://example.com/op"},
1239 "responses":{"200":{"description":"ok"}}
1240 }
1241 }
1242 },
1243 "components":{
1244 "schemas":{
1245 "Foo":{
1246 "type":"object",
1247 "externalDocs":{"description":"d","url":"https://example.com/foo"}
1248 }
1249 }
1250 }
1251 }"#;
1252 let ir = parse_str(src).unwrap().spec.unwrap();
1253 let root = ir.external_docs.expect("root externalDocs");
1254 assert_eq!(root.url, "https://example.com");
1255 assert_eq!(root.description.as_deref(), Some("top"));
1256
1257 let op_docs = ir.operations[0]
1258 .external_docs
1259 .as_ref()
1260 .expect("op externalDocs");
1261 assert_eq!(op_docs.url, "https://example.com/op");
1262 assert!(op_docs.description.is_none());
1263
1264 let foo = ir.types.iter().find(|t| t.id == "Foo").expect("Foo type");
1265 let schema_docs = foo.external_docs.as_ref().expect("schema externalDocs");
1266 assert_eq!(schema_docs.url, "https://example.com/foo");
1267 assert_eq!(schema_docs.description.as_deref(), Some("d"));
1268 }
1269
1270 #[test]
1271 fn external_docs_missing_url_warns_and_drops() {
1272 let src = r#"{
1273 "openapi":"3.0.3",
1274 "info":{"title":"t","version":"1"},
1275 "externalDocs":{"description":"oops"},
1276 "paths":{}
1277 }"#;
1278 let out = parse_str(src).unwrap();
1279 let ir = out.spec.unwrap();
1280 assert!(ir.external_docs.is_none());
1281 assert!(out
1282 .diagnostics
1283 .iter()
1284 .any(|d| d.code == diag::W_EXTERNAL_DOCS_NO_URL));
1285 }
1286
1287 #[test]
1288 fn webhooks_carry_routing_name_and_multiple_methods() {
1289 let src = r#"{
1290 "openapi":"3.1.0",
1291 "info":{"title":"t","version":"1"},
1292 "paths":{},
1293 "webhooks":{
1294 "newPet":{
1295 "post":{
1296 "operationId":"newPetCreated",
1297 "responses":{"200":{"description":"ok"}}
1298 },
1299 "delete":{
1300 "operationId":"newPetDeleted",
1301 "responses":{"200":{"description":"ok"}}
1302 }
1303 }
1304 }
1305 }"#;
1306 let ir = parse_str(src).unwrap().spec.unwrap();
1307 assert_eq!(ir.webhooks.len(), 1);
1308 let w = &ir.webhooks[0];
1309 assert_eq!(w.name, "newPet");
1310 assert_eq!(w.operations.len(), 2);
1313 assert!(w.operations.iter().any(|o| o.id == "newPetCreated"));
1314 assert!(w.operations.iter().any(|o| o.id == "newPetDeleted"));
1315 }
1316
1317 #[test]
1318 fn webhooks_sort_by_name() {
1319 let src = r#"{
1320 "openapi":"3.1.0",
1321 "info":{"title":"t","version":"1"},
1322 "paths":{},
1323 "webhooks":{
1324 "zebra":{"post":{"operationId":"z","responses":{"200":{"description":"ok"}}}},
1325 "alpha":{"post":{"operationId":"a","responses":{"200":{"description":"ok"}}}}
1326 }
1327 }"#;
1328 let ir = parse_str(src).unwrap().spec.unwrap();
1329 assert_eq!(ir.webhooks[0].name, "alpha");
1330 assert_eq!(ir.webhooks[1].name, "zebra");
1331 }
1332
1333 #[test]
1334 fn response_headers_use_dedicated_header_struct() {
1335 let src = r#"{
1336 "openapi":"3.0.3",
1337 "info":{"title":"t","version":"1"},
1338 "paths":{
1339 "/x":{
1340 "get":{
1341 "operationId":"x",
1342 "responses":{
1343 "200":{
1344 "description":"ok",
1345 "headers":{
1346 "X-Trace":{
1347 "description":"trace id",
1348 "required":true,
1349 "schema":{"type":"string"}
1350 }
1351 }
1352 }
1353 }
1354 }
1355 }
1356 }
1357 }"#;
1358 let ir = parse_str(src).unwrap().spec.unwrap();
1359 let resp = &ir.operations[0].responses[0];
1360 assert_eq!(resp.headers.len(), 1);
1361 let (name, header) = &resp.headers[0];
1362 assert_eq!(name, "X-Trace");
1363 assert!(header.required);
1364 assert_eq!(header.description.as_deref(), Some("trace id"));
1365 }
1368
1369 #[test]
1370 fn openid_connect_security_scheme_round_trips() {
1371 let src = r#"{
1372 "openapi":"3.0.3",
1373 "info":{"title":"t","version":"1"},
1374 "paths":{},
1375 "components":{
1376 "securitySchemes":{
1377 "oidc":{
1378 "type":"openIdConnect",
1379 "openIdConnectUrl":"https://example.com/.well-known/openid-configuration"
1380 }
1381 }
1382 }
1383 }"#;
1384 let ir = parse_str(src).unwrap().spec.unwrap();
1385 let scheme = ir
1386 .security_schemes
1387 .iter()
1388 .find(|s| s.id == "oidc")
1389 .expect("oidc scheme present");
1390 match &scheme.kind {
1391 forge_ir::SecuritySchemeKind::OpenIdConnect { url } => {
1392 assert_eq!(url, "https://example.com/.well-known/openid-configuration");
1393 }
1394 other => panic!("expected OpenIdConnect, got {other:?}"),
1395 }
1396 }
1397
1398 #[test]
1399 fn openid_connect_missing_url_errors() {
1400 let src = r#"{
1401 "openapi":"3.0.3",
1402 "info":{"title":"t","version":"1"},
1403 "paths":{},
1404 "components":{
1405 "securitySchemes":{
1406 "oidc":{"type":"openIdConnect"}
1407 }
1408 }
1409 }"#;
1410 let out = parse_str(src).unwrap();
1411 assert!(out.spec.unwrap().security_schemes.is_empty());
1413 assert!(out
1415 .diagnostics
1416 .iter()
1417 .any(|d| d.code == diag::E_MISSING_FIELD));
1418 }
1419
1420 #[test]
1421 fn ref_siblings_warn_on_oas_3_0() {
1422 let src = r##"{
1423 "openapi":"3.0.3",
1424 "info":{"title":"t","version":"1"},
1425 "paths":{},
1426 "components":{
1427 "schemas":{
1428 "A":{"type":"string"},
1429 "B":{"$ref":"#/components/schemas/A","description":"sibling"}
1430 }
1431 }
1432 }"##;
1433 let out = parse_str(src).unwrap();
1434 let diags = out.diagnostics;
1435 let warning = diags
1436 .iter()
1437 .find(|d| d.code == diag::W_REF_SIBLINGS_3_0)
1438 .expect("warning emitted");
1439 assert!(warning.message.contains("description"));
1440 }
1441
1442 #[test]
1443 fn ref_siblings_dont_warn_on_oas_3_1() {
1444 let src = r##"{
1445 "openapi":"3.1.0",
1446 "info":{"title":"t","version":"1"},
1447 "paths":{},
1448 "components":{
1449 "schemas":{
1450 "A":{"type":"string"},
1451 "B":{"$ref":"#/components/schemas/A","description":"sibling"}
1452 }
1453 }
1454 }"##;
1455 let out = parse_str(src).unwrap();
1456 assert!(!out
1457 .diagnostics
1458 .iter()
1459 .any(|d| d.code == diag::W_REF_SIBLINGS_3_0));
1460 }
1461
1462 #[test]
1463 fn ref_with_only_x_extensions_does_not_warn() {
1464 let src = r##"{
1466 "openapi":"3.0.3",
1467 "info":{"title":"t","version":"1"},
1468 "paths":{},
1469 "components":{
1470 "schemas":{
1471 "A":{"type":"string"},
1472 "B":{"$ref":"#/components/schemas/A","x-vendor":"acme"}
1473 }
1474 }
1475 }"##;
1476 let out = parse_str(src).unwrap();
1477 assert!(!out
1478 .diagnostics
1479 .iter()
1480 .any(|d| d.code == diag::W_REF_SIBLINGS_3_0));
1481 }
1482
1483 #[test]
1484 fn referenced_component_path_item_lands_in_operations() {
1485 let src = r##"{
1486 "openapi":"3.1.0",
1487 "info":{"title":"t","version":"1"},
1488 "paths":{
1489 "/items":{"$ref":"#/components/pathItems/ItemsPath"}
1490 },
1491 "components":{
1492 "pathItems":{
1493 "ItemsPath":{
1494 "get":{"operationId":"list","responses":{"200":{"description":"ok"}}}
1495 }
1496 }
1497 }
1498 }"##;
1499 let out = parse_str(src).unwrap();
1500 let ir = out.spec.unwrap();
1501 assert!(ir.operations.iter().any(|o| o.id == "list"));
1503 assert!(!out
1505 .diagnostics
1506 .iter()
1507 .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1508 }
1509
1510 #[test]
1511 fn unused_component_path_item_warns() {
1512 let src = r##"{
1513 "openapi":"3.1.0",
1514 "info":{"title":"t","version":"1"},
1515 "paths":{},
1516 "components":{
1517 "pathItems":{
1518 "Orphan":{
1519 "get":{"operationId":"orphan","responses":{"200":{"description":"ok"}}}
1520 }
1521 }
1522 }
1523 }"##;
1524 let out = parse_str(src).unwrap();
1525 let ir = out.spec.unwrap();
1526 assert!(ir.operations.is_empty());
1528 assert!(out
1529 .diagnostics
1530 .iter()
1531 .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1532 }
1533
1534 #[test]
1535 fn webhook_ref_into_component_path_item_counts_as_use() {
1536 let src = r##"{
1537 "openapi":"3.1.0",
1538 "info":{"title":"t","version":"1"},
1539 "paths":{},
1540 "webhooks":{
1541 "ev":{"$ref":"#/components/pathItems/EventPath"}
1542 },
1543 "components":{
1544 "pathItems":{
1545 "EventPath":{
1546 "post":{"operationId":"ev","responses":{"200":{"description":"ok"}}}
1547 }
1548 }
1549 }
1550 }"##;
1551 let out = parse_str(src).unwrap();
1552 assert!(!out
1554 .diagnostics
1555 .iter()
1556 .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1557 }
1558
1559 #[test]
1560 fn callbacks_walk_inline_and_via_ref() {
1561 let src = r##"{
1562 "openapi":"3.0.3",
1563 "info":{"title":"t","version":"1"},
1564 "paths":{
1565 "/sub":{
1566 "post":{
1567 "operationId":"sub",
1568 "responses":{"200":{"description":"ok"}},
1569 "callbacks":{
1570 "evt":{
1571 "{$request.body#/url}":{
1572 "post":{
1573 "operationId":"evtCb",
1574 "responses":{"200":{"description":"ok"}}
1575 }
1576 }
1577 },
1578 "shared":{"$ref":"#/components/callbacks/Shared"}
1579 }
1580 }
1581 }
1582 },
1583 "components":{
1584 "callbacks":{
1585 "Shared":{
1586 "{$request.body#/sharedUrl}":{
1587 "post":{
1588 "operationId":"sharedCb",
1589 "responses":{"200":{"description":"ok"}}
1590 }
1591 }
1592 }
1593 }
1594 }
1595 }"##;
1596 let ir = parse_str(src).unwrap().spec.unwrap();
1597 let sub = ir.operations.iter().find(|o| o.id == "sub").unwrap();
1598 assert_eq!(sub.callbacks.len(), 2);
1599 let evt = sub.callbacks.iter().find(|c| c.name == "evt").unwrap();
1600 assert_eq!(evt.expression, "{$request.body#/url}");
1601 assert_eq!(evt.operation_ids, vec!["evtCb".to_string()]);
1602 let shared = sub.callbacks.iter().find(|c| c.name == "shared").unwrap();
1603 assert_eq!(shared.expression, "{$request.body#/sharedUrl}");
1604 assert_eq!(shared.operation_ids, vec!["sharedCb".to_string()]);
1605 assert!(ir.operations.iter().any(|o| o.id == "evtCb"));
1608 assert!(ir.operations.iter().any(|o| o.id == "sharedCb"));
1609 }
1610
1611 #[test]
1612 fn callback_op_id_collides_with_top_level_emits_dup_error() {
1613 let src = r##"{
1616 "openapi":"3.0.3",
1617 "info":{"title":"t","version":"1"},
1618 "paths":{
1619 "/a":{
1620 "post":{
1621 "operationId":"foo",
1622 "responses":{"200":{"description":"ok"}},
1623 "callbacks":{
1624 "x":{
1625 "{$req}":{
1626 "post":{
1627 "operationId":"foo",
1628 "responses":{"200":{"description":"ok"}}
1629 }
1630 }
1631 }
1632 }
1633 }
1634 }
1635 }
1636 }"##;
1637 let out = parse_str(src).unwrap();
1638 assert!(out
1639 .diagnostics
1640 .iter()
1641 .any(|d| d.code == diag::E_DUPLICATE_OPERATION_ID));
1642 }
1643
1644 #[test]
1645 fn response_links_populate_inline_and_via_ref() {
1646 let src = r##"{
1647 "openapi":"3.0.3",
1648 "info":{"title":"t","version":"1"},
1649 "paths":{
1650 "/u":{
1651 "get":{
1652 "operationId":"getU",
1653 "responses":{
1654 "200":{
1655 "description":"ok",
1656 "links":{
1657 "addr":{
1658 "operationId":"getA",
1659 "parameters":{"id":"$response.body#/id"},
1660 "description":"docs"
1661 },
1662 "shared":{"$ref":"#/components/links/Shared"}
1663 }
1664 }
1665 }
1666 }
1667 },
1668 "/a":{
1669 "get":{
1670 "operationId":"getA",
1671 "responses":{"200":{"description":"ok"}}
1672 }
1673 }
1674 },
1675 "components":{
1676 "links":{
1677 "Shared":{"operationId":"getA","description":"shared"}
1678 }
1679 }
1680 }"##;
1681 let ir = parse_str(src).unwrap().spec.unwrap();
1682 let op = ir.operations.iter().find(|o| o.id == "getU").unwrap();
1683 let links = &op.responses[0].links;
1684 assert_eq!(links.len(), 2);
1685 let addr = &links.iter().find(|(k, _)| k == "addr").unwrap().1;
1686 assert_eq!(addr.operation_id.as_deref(), Some("getA"));
1687 assert_eq!(addr.parameters.len(), 1);
1688 assert_eq!(addr.parameters[0].0, "id");
1689 assert_eq!(addr.description.as_deref(), Some("docs"));
1690 let shared = &links.iter().find(|(k, _)| k == "shared").unwrap().1;
1691 assert_eq!(shared.description.as_deref(), Some("shared"));
1692 assert_eq!(shared.operation_id.as_deref(), Some("getA"));
1693 }
1694
1695 #[test]
1696 fn link_with_both_operation_ref_and_id_keeps_ref() {
1697 let src = r##"{
1698 "openapi":"3.0.3",
1699 "info":{"title":"t","version":"1"},
1700 "paths":{
1701 "/u":{
1702 "get":{
1703 "operationId":"getU",
1704 "responses":{
1705 "200":{
1706 "description":"ok",
1707 "links":{
1708 "x":{
1709 "operationRef":"#/paths/~1a/get",
1710 "operationId":"getA"
1711 }
1712 }
1713 }
1714 }
1715 }
1716 }
1717 }
1718 }"##;
1719 let out = parse_str(src).unwrap();
1720 let ir = out.spec.unwrap();
1721 let link = &ir.operations[0].responses[0].links[0].1;
1722 assert!(link.operation_ref.is_some());
1723 assert!(link.operation_id.is_none());
1724 assert!(out
1725 .diagnostics
1726 .iter()
1727 .any(|d| d.code == diag::E_LINK_OP_CONFLICT));
1728 }
1729
1730 #[test]
1731 fn link_compound_parameter_survives_via_value_pool() {
1732 let src = r##"{
1733 "openapi":"3.0.3",
1734 "info":{"title":"t","version":"1"},
1735 "paths":{
1736 "/u":{
1737 "get":{
1738 "operationId":"getU",
1739 "responses":{
1740 "200":{
1741 "description":"ok",
1742 "links":{
1743 "x":{
1744 "operationId":"foo",
1745 "parameters":{"complex":["a","b"]}
1746 }
1747 }
1748 }
1749 }
1750 }
1751 }
1752 }
1753 }"##;
1754 let out = parse_str(src).unwrap();
1755 let ir = out.spec.unwrap();
1756 let link = &ir.operations[0].responses[0].links[0].1;
1757 assert_eq!(link.parameters.len(), 1);
1760 let r = link.parameters[0].1 as usize;
1761 assert!(matches!(ir.values[r], forge_ir::Value::List { .. }));
1762 }
1763
1764 #[test]
1765 fn xml_block_populates_with_all_fields() {
1766 let src = r#"{
1767 "openapi":"3.0.3",
1768 "info":{"title":"t","version":"1"},
1769 "paths":{},
1770 "components":{
1771 "schemas":{
1772 "Pet":{
1773 "type":"object",
1774 "xml":{
1775 "name":"Pet",
1776 "namespace":"http://example.com/pet",
1777 "prefix":"pt",
1778 "attribute":false,
1779 "wrapped":true,
1780 "x-vendor":"acme"
1781 }
1782 }
1783 }
1784 }
1785 }"#;
1786 let ir = parse_str(src).unwrap().spec.unwrap();
1787 let pet = ir.types.iter().find(|t| t.id == "Pet").unwrap();
1788 let xml = pet.xml.as_ref().expect("xml populated");
1789 assert_eq!(xml.name.as_deref(), Some("Pet"));
1790 assert_eq!(xml.namespace.as_deref(), Some("http://example.com/pet"));
1791 assert_eq!(xml.prefix.as_deref(), Some("pt"));
1792 assert!(!xml.attribute);
1793 assert!(xml.wrapped);
1794 assert_eq!(xml.extensions.len(), 1);
1795 assert_eq!(xml.extensions[0].0, "x-vendor");
1796 }
1797
1798 #[test]
1799 fn xml_attribute_defaults_to_false() {
1800 let src = r#"{
1801 "openapi":"3.0.3",
1802 "info":{"title":"t","version":"1"},
1803 "paths":{},
1804 "components":{
1805 "schemas":{
1806 "Foo":{"type":"string","xml":{"name":"Foo"}}
1807 }
1808 }
1809 }"#;
1810 let ir = parse_str(src).unwrap().spec.unwrap();
1811 let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1812 let xml = foo.xml.as_ref().unwrap();
1813 assert!(!xml.attribute);
1814 assert!(!xml.wrapped);
1815 }
1816
1817 #[test]
1818 fn xml_absent_leaves_field_none() {
1819 let src = r#"{
1820 "openapi":"3.0.3",
1821 "info":{"title":"t","version":"1"},
1822 "paths":{},
1823 "components":{"schemas":{"Foo":{"type":"string"}}}
1824 }"#;
1825 let ir = parse_str(src).unwrap().spec.unwrap();
1826 let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1827 assert!(foo.xml.is_none());
1828 }
1829
1830 #[test]
1831 fn examples_populate_at_parameter_and_schema_sites() {
1832 let src = r##"{
1833 "openapi":"3.0.3",
1834 "info":{"title":"t","version":"1"},
1835 "paths":{
1836 "/x/{id}":{
1837 "get":{
1838 "operationId":"getX",
1839 "parameters":[{
1840 "name":"id","in":"path","required":true,
1841 "schema":{"type":"string"},
1842 "examples":{
1843 "short":{"summary":"S","value":"42"},
1844 "uuid":{"$ref":"#/components/examples/UuidExample"}
1845 }
1846 }],
1847 "responses":{"204":{"description":"ok"}}
1848 }
1849 }
1850 },
1851 "components":{
1852 "examples":{
1853 "UuidExample":{"summary":"UUID","value":"abc"}
1854 },
1855 "schemas":{
1856 "Foo":{"type":"string","example":"hello"}
1857 }
1858 }
1859 }"##;
1860 let ir = parse_str(src).unwrap().spec.unwrap();
1861 let param = &ir.operations[0].path_params[0];
1863 assert_eq!(param.examples.len(), 2);
1864 assert_eq!(param.examples[0].0, "short");
1865 let r0 = param.examples[0].1.value.unwrap() as usize;
1866 assert_eq!(ir.values[r0], forge_ir::Value::s("42"));
1867 assert_eq!(param.examples[1].0, "uuid");
1868 let r1 = param.examples[1].1.value.unwrap() as usize;
1869 assert_eq!(ir.values[r1], forge_ir::Value::s("abc"));
1870 let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1872 assert_eq!(foo.examples.len(), 1);
1873 assert_eq!(foo.examples[0].0, "_default");
1874 let r2 = foo.examples[0].1.value.unwrap() as usize;
1875 assert_eq!(ir.values[r2], forge_ir::Value::s("hello"));
1876 }
1877
1878 #[test]
1879 fn compound_example_survives_via_value_pool() {
1880 let src = r#"{
1881 "openapi":"3.0.3",
1882 "info":{"title":"t","version":"1"},
1883 "paths":{},
1884 "components":{
1885 "schemas":{
1886 "Foo":{"type":"object","example":{"k":"v"}}
1887 }
1888 }
1889 }"#;
1890 let out = parse_str(src).unwrap();
1891 let ir = out.spec.unwrap();
1892 let foo = ir.types.iter().find(|t| t.id == "Foo").cloned().unwrap();
1893 assert_eq!(foo.examples.len(), 1);
1895 assert_eq!(foo.examples[0].0, "_default");
1896 let r = foo.examples[0].1.value.unwrap() as usize;
1897 let resolved = &ir.values[r];
1898 let forge_ir::Value::Object { fields } = resolved else {
1899 panic!("expected object example, got {resolved:?}");
1900 };
1901 assert_eq!(fields.len(), 1);
1902 assert_eq!(fields[0].0, "k");
1903 assert_eq!(ir.values[fields[0].1 as usize], forge_ir::Value::s("v"));
1904 }
1905
1906 #[test]
1907 fn example_with_value_and_external_value_keeps_value() {
1908 let src = r##"{
1909 "openapi":"3.0.3",
1910 "info":{"title":"t","version":"1"},
1911 "paths":{},
1912 "components":{
1913 "examples":{
1914 "Conflict":{
1915 "value":"inline",
1916 "externalValue":"https://example.com/blob"
1917 }
1918 },
1919 "schemas":{
1920 "Foo":{
1921 "type":"string",
1922 "examples":{"a":{"$ref":"#/components/examples/Conflict"}}
1923 }
1924 }
1925 }
1926 }"##;
1927 let out = parse_str(src).unwrap();
1928 let ir = out.spec.as_ref().unwrap();
1929 let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1930 let ex = &foo.examples[0].1;
1931 let r = ex.value.unwrap() as usize;
1932 assert_eq!(ir.values[r], forge_ir::Value::s("inline"));
1933 assert!(ex.external_value.is_none());
1934 assert!(out
1935 .diagnostics
1936 .iter()
1937 .any(|d| d.code == diag::E_EXAMPLE_VALUE_CONFLICT));
1938 }
1939
1940 #[test]
1941 fn json_schema_examples_array_lowers_at_schema_and_property_sites() {
1942 let src = r##"{
1946 "openapi":"3.1.0",
1947 "info":{"title":"t","version":"1"},
1948 "paths":{},
1949 "components":{
1950 "schemas":{
1951 "ArrayBadSchema":{
1952 "type":"integer","format":"int32",
1953 "examples":["not-an-int", 7]
1954 },
1955 "ObjArrayBadProp":{
1956 "type":"object",
1957 "properties":{
1958 "n":{"type":"integer","format":"int32","examples":["also-not-an-int"]}
1959 },
1960 "required":["n"],
1961 "additionalProperties":false
1962 }
1963 }
1964 }
1965 }"##;
1966 let ir = parse_str(src).unwrap().spec.unwrap();
1967 let arr = ir.types.iter().find(|t| t.id == "ArrayBadSchema").unwrap();
1969 assert_eq!(arr.examples.len(), 2);
1970 assert_eq!(arr.examples[0].0, "_examples[0]");
1971 let r0 = arr.examples[0].1.value.unwrap() as usize;
1972 assert_eq!(ir.values[r0], forge_ir::Value::s("not-an-int"));
1973 assert_eq!(arr.examples[1].0, "_examples[1]");
1974 let r1 = arr.examples[1].1.value.unwrap() as usize;
1975 assert_eq!(ir.values[r1], forge_ir::Value::Int { value: 7 });
1976 let obj_ty = ir.types.iter().find(|t| t.id == "ObjArrayBadProp").unwrap();
1978 let forge_ir::TypeDef::Object(obj) = &obj_ty.definition else {
1979 panic!("expected object");
1980 };
1981 let n = obj.properties.iter().find(|p| p.name == "n").unwrap();
1982 assert_eq!(n.examples.len(), 1);
1983 assert_eq!(n.examples[0].0, "_examples[0]");
1984 let rp = n.examples[0].1.value.unwrap() as usize;
1985 assert_eq!(ir.values[rp], forge_ir::Value::s("also-not-an-int"));
1986 }
1987
1988 #[test]
1989 fn singular_example_and_examples_array_coexist() {
1990 let src = r##"{
1993 "openapi":"3.1.0",
1994 "info":{"title":"t","version":"1"},
1995 "paths":{},
1996 "components":{
1997 "schemas":{
1998 "Both":{"type":"string","example":"a","examples":["b","c"]}
1999 }
2000 }
2001 }"##;
2002 let ir = parse_str(src).unwrap().spec.unwrap();
2003 let both = ir.types.iter().find(|t| t.id == "Both").unwrap();
2004 let keys: Vec<&str> = both.examples.iter().map(|(k, _)| k.as_str()).collect();
2005 assert_eq!(keys, ["_default", "_examples[0]", "_examples[1]"]);
2006 }
2007
2008 #[test]
2009 fn item_schema_populates_item_schema_and_type() {
2010 let src = r##"{
2014 "openapi":"3.2.0",
2015 "info":{"title":"t","version":"1"},
2016 "paths":{
2017 "/events":{
2018 "get":{
2019 "operationId":"stream",
2020 "responses":{
2021 "200":{
2022 "description":"jsonl",
2023 "content":{
2024 "application/jsonl":{
2025 "itemSchema":{"$ref":"#/components/schemas/Event"}
2026 }
2027 }
2028 }
2029 }
2030 }
2031 }
2032 },
2033 "components":{
2034 "schemas":{
2035 "Event":{"type":"object","properties":{"id":{"type":"string"}}}
2036 }
2037 }
2038 }"##;
2039 let ir = parse_str(src).unwrap().spec.unwrap();
2040 let op = &ir.operations[0];
2041 let content = &op.responses[0].content[0];
2042 assert_eq!(content.media_type, "application/jsonl");
2043 assert_eq!(content.r#type, "Event");
2044 assert_eq!(content.item_schema.as_deref(), Some("Event"));
2045 }
2046
2047 #[test]
2048 fn schema_only_leaves_item_schema_none() {
2049 let src = r#"{
2051 "openapi":"3.0.3",
2052 "info":{"title":"t","version":"1"},
2053 "paths":{
2054 "/x":{
2055 "get":{
2056 "operationId":"x",
2057 "responses":{
2058 "200":{"description":"ok","content":{
2059 "application/json":{"schema":{"type":"string"}}
2060 }}
2061 }
2062 }
2063 }
2064 }
2065 }"#;
2066 let ir = parse_str(src).unwrap().spec.unwrap();
2067 let content = &ir.operations[0].responses[0].content[0];
2068 assert!(content.item_schema.is_none());
2069 }
2070
2071 #[test]
2072 fn schema_and_item_schema_together_emit_conflict_error() {
2073 let src = r#"{
2074 "openapi":"3.2.0",
2075 "info":{"title":"t","version":"1"},
2076 "paths":{
2077 "/x":{
2078 "get":{
2079 "operationId":"x",
2080 "responses":{
2081 "200":{"description":"ok","content":{
2082 "application/json":{
2083 "schema":{"type":"string"},
2084 "itemSchema":{"type":"string"}
2085 }
2086 }}
2087 }
2088 }
2089 }
2090 }
2091 }"#;
2092 let out = parse_str(src).unwrap();
2093 assert!(out
2094 .diagnostics
2095 .iter()
2096 .any(|d| d.code == diag::E_CONTENT_SCHEMA_CONFLICT));
2097 }
2098
2099 #[test]
2100 fn additional_operations_walk_into_other_method() {
2101 let src = r#"{
2102 "openapi":"3.2.0",
2103 "info":{"title":"t","version":"1"},
2104 "paths":{
2105 "/items":{
2106 "get":{"operationId":"listItems","responses":{"204":{"description":"ok"}}},
2107 "additionalOperations":{
2108 "QUERY":{
2109 "operationId":"queryItems",
2110 "responses":{"204":{"description":"ok"}}
2111 }
2112 }
2113 }
2114 }
2115 }"#;
2116 let ir = parse_str(src).unwrap().spec.unwrap();
2117 let query_op = ir
2118 .operations
2119 .iter()
2120 .find(|o| o.id == "queryItems")
2121 .expect("queryItems present");
2122 assert_eq!(query_op.method, forge_ir::HttpMethod::Other("QUERY".into()));
2123 let list_op = ir.operations.iter().find(|o| o.id == "listItems").unwrap();
2125 assert_eq!(list_op.method, forge_ir::HttpMethod::Get);
2126 }
2127
2128 #[test]
2129 fn additional_operations_method_normalised_to_uppercase() {
2130 let src = r#"{
2135 "openapi":"3.2.0",
2136 "info":{"title":"t","version":"1"},
2137 "paths":{
2138 "/x":{
2139 "additionalOperations":{
2140 "Query":{
2141 "operationId":"qx",
2142 "responses":{"204":{"description":"ok"}}
2143 }
2144 }
2145 }
2146 }
2147 }"#;
2148 let ir = parse_str(src).unwrap().spec.unwrap();
2149 assert_eq!(
2150 ir.operations[0].method,
2151 forge_ir::HttpMethod::Other("QUERY".into())
2152 );
2153 }
2154
2155 #[test]
2156 fn http_method_as_str_returns_wire_form() {
2157 use forge_ir::HttpMethod as M;
2158 assert_eq!(M::Get.as_str(), "GET");
2159 assert_eq!(M::Patch.as_str(), "PATCH");
2160 assert_eq!(M::Other("QUERY".into()).as_str(), "QUERY");
2161 }
2162
2163 #[test]
2164 fn schema_defaults_populate_named_type_and_property() {
2165 let src = r#"{
2166 "openapi":"3.0.3",
2167 "info":{"title":"t","version":"1"},
2168 "paths":{},
2169 "components":{
2170 "schemas":{
2171 "PageSize":{"type":"integer","default":25},
2172 "Pet":{
2173 "type":"object",
2174 "properties":{
2175 "name":{"type":"string","default":"Rex"}
2176 }
2177 }
2178 }
2179 }
2180 }"#;
2181 let ir = parse_str(src).unwrap().spec.unwrap();
2182 let page_size = ir.types.iter().find(|t| t.id == "PageSize").unwrap();
2183 let r = page_size.default.unwrap() as usize;
2184 assert_eq!(ir.values[r], forge_ir::Value::Int { value: 25 });
2185 let pet = ir.types.iter().find(|t| t.id == "Pet").unwrap();
2186 let forge_ir::TypeDef::Object(pet_obj) = &pet.definition else {
2187 panic!("Pet should be object");
2188 };
2189 let name_prop = pet_obj
2190 .properties
2191 .iter()
2192 .find(|p| p.name == "name")
2193 .unwrap();
2194 let r = name_prop.default.unwrap() as usize;
2195 assert_eq!(ir.values[r], forge_ir::Value::s("Rex"));
2196 }
2197
2198 #[test]
2199 fn schema_default_null_round_trips() {
2200 let src = r#"{
2202 "openapi":"3.0.3",
2203 "info":{"title":"t","version":"1"},
2204 "paths":{},
2205 "components":{
2206 "schemas":{
2207 "Empty":{"type":"string","default":null}
2208 }
2209 }
2210 }"#;
2211 let ir = parse_str(src).unwrap().spec.unwrap();
2212 let empty = ir.types.iter().find(|t| t.id == "Empty").unwrap();
2213 let r = empty.default.unwrap() as usize;
2214 assert_eq!(ir.values[r], forge_ir::Value::Null);
2215 }
2216
2217 #[test]
2218 fn empty_and_freeform_schemas_lower_to_any_not_object() {
2219 let src = r#"{
2226 "openapi":"3.1.0",
2227 "info":{"title":"t","version":"1"},
2228 "paths":{},
2229 "components":{
2230 "schemas":{
2231 "AnyVal":{},
2232 "OpaqueDoc":{"description":"any JSON value"},
2233 "RealObject":{"type":"object"}
2234 }
2235 }
2236 }"#;
2237 let ir = parse_str(src).unwrap().spec.unwrap();
2238 let def = |id: &str| {
2239 &ir.types
2240 .iter()
2241 .find(|t| t.id == id)
2242 .unwrap_or_else(|| panic!("missing type {id}"))
2243 .definition
2244 };
2245 assert!(
2246 matches!(def("AnyVal"), forge_ir::TypeDef::Any),
2247 "`{{}}` must lower to Any, got {:?}",
2248 def("AnyVal")
2249 );
2250 assert!(
2251 matches!(def("OpaqueDoc"), forge_ir::TypeDef::Any),
2252 "an annotation-only schema must lower to Any, got {:?}",
2253 def("OpaqueDoc")
2254 );
2255 assert!(
2257 matches!(def("RealObject"), forge_ir::TypeDef::Object(_)),
2258 "`{{\"type\":\"object\"}}` must stay Object, got {:?}",
2259 def("RealObject")
2260 );
2261 }
2262
2263 #[test]
2264 fn schema_compound_default_survives_via_value_pool() {
2265 let src = r#"{
2266 "openapi":"3.0.3",
2267 "info":{"title":"t","version":"1"},
2268 "paths":{},
2269 "components":{
2270 "schemas":{
2271 "Cfg":{"type":"object","default":{"k":"v"}}
2272 }
2273 }
2274 }"#;
2275 let out = parse_str(src).unwrap();
2276 let ir = out.spec.unwrap();
2277 let cfg = ir.types.iter().find(|t| t.id == "Cfg").unwrap();
2278 let r = cfg.default.unwrap() as usize;
2279 let forge_ir::Value::Object { fields } = &ir.values[r] else {
2280 panic!("expected object default");
2281 };
2282 assert_eq!(fields.len(), 1);
2283 assert_eq!(fields[0].0, "k");
2284 assert_eq!(ir.values[fields[0].1 as usize], forge_ir::Value::s("v"));
2285 }
2286
2287 #[test]
2288 fn tags_walk_into_structured_records() {
2289 let src = r#"{
2290 "openapi":"3.2.0",
2291 "info":{"title":"t","version":"1"},
2292 "tags":[
2293 {
2294 "name":"pets",
2295 "summary":"S",
2296 "description":"D",
2297 "kind":"audience",
2298 "externalDocs":{"url":"https://example.com"}
2299 },
2300 {"name":"cats","parent":"pets"}
2301 ],
2302 "paths":{}
2303 }"#;
2304 let ir = parse_str(src).unwrap().spec.unwrap();
2305 assert_eq!(ir.tags[0].name, "cats");
2307 assert_eq!(ir.tags[0].parent.as_deref(), Some("pets"));
2308 assert_eq!(ir.tags[1].name, "pets");
2309 assert_eq!(ir.tags[1].summary.as_deref(), Some("S"));
2310 assert_eq!(ir.tags[1].description.as_deref(), Some("D"));
2311 assert_eq!(ir.tags[1].kind.as_deref(), Some("audience"));
2312 assert_eq!(
2313 ir.tags[1].external_docs.as_ref().unwrap().url,
2314 "https://example.com"
2315 );
2316 }
2317
2318 #[test]
2319 fn tag_parent_dangling_drops_ref_keeps_tag() {
2320 let src = r#"{
2321 "openapi":"3.2.0",
2322 "info":{"title":"t","version":"1"},
2323 "tags":[
2324 {"name":"cats","parent":"no-such-tag"}
2325 ],
2326 "paths":{}
2327 }"#;
2328 let out = parse_str(src).unwrap();
2329 let ir = out.spec.unwrap();
2330 assert_eq!(ir.tags.len(), 1);
2331 assert_eq!(ir.tags[0].name, "cats");
2332 assert!(ir.tags[0].parent.is_none());
2334 assert!(out
2335 .diagnostics
2336 .iter()
2337 .any(|d| d.code == diag::W_TAG_PARENT_DANGLING));
2338 }
2339
2340 #[test]
2341 fn tags_extensions_round_trip() {
2342 let src = r#"{
2343 "openapi":"3.0.3",
2344 "info":{"title":"t","version":"1"},
2345 "tags":[
2346 {"name":"pets","x-priority":5}
2347 ],
2348 "paths":{}
2349 }"#;
2350 let ir = parse_str(src).unwrap().spec.unwrap();
2351 let ext = &ir.tags[0].extensions;
2352 assert_eq!(ext.len(), 1);
2353 assert_eq!(ext[0].0, "x-priority");
2354 }
2355
2356 #[test]
2357 fn operation_servers_resolution_picks_most_specific() {
2358 let src = r#"{
2359 "openapi":"3.0.3",
2360 "info":{"title":"t","version":"1"},
2361 "servers":[{"url":"https://root"}],
2362 "paths":{
2363 "/a":{
2364 "get":{"operationId":"opA","responses":{"204":{"description":"ok"}}}
2365 },
2366 "/b":{
2367 "servers":[{"url":"https://path-b"}],
2368 "get":{"operationId":"opB","responses":{"204":{"description":"ok"}}},
2369 "post":{
2370 "operationId":"opC",
2371 "servers":[{"url":"https://op-c"}],
2372 "responses":{"204":{"description":"ok"}}
2373 }
2374 }
2375 }
2376 }"#;
2377 let ir = parse_str(src).unwrap().spec.unwrap();
2378 let by_id = |id: &str| {
2379 ir.operations
2380 .iter()
2381 .find(|o| o.id == id)
2382 .unwrap_or_else(|| panic!("operation {id} not found"))
2383 };
2384 assert_eq!(by_id("opA").servers[0].url, "https://root");
2385 assert_eq!(by_id("opB").servers[0].url, "https://path-b");
2386 assert_eq!(by_id("opC").servers[0].url, "https://op-c");
2387 }
2388
2389 #[test]
2390 fn operation_servers_empty_when_no_root_or_overrides() {
2391 let src = r#"{
2394 "openapi":"3.0.3",
2395 "info":{"title":"t","version":"1"},
2396 "paths":{
2397 "/x":{"get":{"operationId":"x","responses":{"204":{"description":"ok"}}}}
2398 }
2399 }"#;
2400 let ir = parse_str(src).unwrap().spec.unwrap();
2401 assert!(ir.operations[0].servers.is_empty());
2402 assert!(ir.servers.is_empty());
2403 }
2404
2405 #[test]
2406 fn operation_servers_explicit_empty_array_falls_through_to_root() {
2407 let src = r#"{
2412 "openapi":"3.0.3",
2413 "info":{"title":"t","version":"1"},
2414 "servers":[{"url":"https://root"}],
2415 "paths":{
2416 "/x":{"get":{
2417 "operationId":"x",
2418 "servers":[],
2419 "responses":{"204":{"description":"ok"}}
2420 }}
2421 }
2422 }"#;
2423 let ir = parse_str(src).unwrap().spec.unwrap();
2424 assert_eq!(ir.operations[0].servers[0].url, "https://root");
2425 }
2426
2427 #[test]
2428 fn external_docs_absent_leaves_field_none() {
2429 let src = r#"{
2430 "openapi":"3.0.3",
2431 "info":{"title":"t","version":"1"},
2432 "paths":{}
2433 }"#;
2434 let ir = parse_str(src).unwrap().spec.unwrap();
2435 assert!(ir.external_docs.is_none());
2436 }
2437
2438 #[test]
2439 fn info_license_name_only_round_trips() {
2440 let src = r#"{
2443 "openapi":"3.0.0",
2444 "info":{
2445 "title":"t",
2446 "version":"1",
2447 "license":{"name":"MIT"}
2448 },
2449 "paths":{}
2450 }"#;
2451 let ir = parse_str(src).unwrap().spec.unwrap();
2452 assert_eq!(ir.info.license_name.as_deref(), Some("MIT"));
2453 assert!(ir.info.license_url.is_none());
2454 assert!(ir.info.license_identifier.is_none());
2455 }
2456
2457 #[test]
2458 fn extensions_populate_on_every_specification_object() {
2459 use forge_ir::{SecuritySchemeKind, TypeDef};
2460 let src = r##"{
2465 "openapi":"3.0.3",
2466 "info":{
2467 "title":"t",
2468 "version":"1",
2469 "x-info":"info-ext"
2470 },
2471 "servers":[{
2472 "url":"https://api.example.com/{tier}",
2473 "x-server":"server-ext",
2474 "variables":{
2475 "tier":{
2476 "default":"v1",
2477 "x-var":"var-ext"
2478 }
2479 }
2480 }],
2481 "paths":{
2482 "/things":{
2483 "post":{
2484 "operationId":"create",
2485 "parameters":[{
2486 "name":"q",
2487 "in":"query",
2488 "schema":{"type":"string"},
2489 "x-param":"param-ext"
2490 }],
2491 "requestBody":{
2492 "x-body":"body-ext",
2493 "content":{
2494 "multipart/form-data":{
2495 "x-content":"content-ext",
2496 "schema":{"$ref":"#/components/schemas/Thing"},
2497 "encoding":{
2498 "name":{
2499 "contentType":"text/plain",
2500 "x-encoding":"encoding-ext"
2501 }
2502 }
2503 }
2504 }
2505 },
2506 "responses":{
2507 "200":{
2508 "description":"ok",
2509 "x-response":"response-ext"
2510 }
2511 }
2512 }
2513 }
2514 },
2515 "components":{
2516 "schemas":{
2517 "Thing":{
2518 "type":"object",
2519 "x-schema":"schema-ext",
2520 "properties":{
2521 "name":{
2522 "type":"string",
2523 "x-prop":"prop-ext"
2524 }
2525 }
2526 }
2527 },
2528 "securitySchemes":{
2529 "OAuth":{
2530 "type":"oauth2",
2531 "x-scheme":"scheme-ext",
2532 "flows":{
2533 "authorizationCode":{
2534 "authorizationUrl":"https://a",
2535 "tokenUrl":"https://t",
2536 "scopes":{},
2537 "x-flow":"flow-ext"
2538 }
2539 }
2540 }
2541 }
2542 }
2543 }"##;
2544 let ir = parse_str(src).unwrap().spec.unwrap();
2545
2546 let exts = &ir.info.extensions;
2548 assert!(
2549 exts.iter().any(|(k, _)| k == "x-info"),
2550 "info.extensions missing x-info: {exts:?}"
2551 );
2552
2553 let server = &ir.servers[0];
2555 assert!(server.extensions.iter().any(|(k, _)| k == "x-server"));
2556 let (_var_name, var) = &server.variables[0];
2557 assert!(var.extensions.iter().any(|(k, _)| k == "x-var"));
2558
2559 let thing = ir.types.iter().find(|t| t.id == "Thing").unwrap();
2561 assert!(thing.extensions.iter().any(|(k, _)| k == "x-schema"));
2562 let TypeDef::Object(obj) = &thing.definition else {
2563 panic!("expected object")
2564 };
2565 let name_prop = obj.properties.iter().find(|p| p.name == "name").unwrap();
2566 assert!(name_prop.extensions.iter().any(|(k, _)| k == "x-prop"));
2567
2568 let op = &ir.operations[0];
2570 let p = &op.query_params[0];
2571 assert!(p.extensions.iter().any(|(k, _)| k == "x-param"));
2572 let body = op.request_body.as_ref().unwrap();
2573 assert!(body.extensions.iter().any(|(k, _)| k == "x-body"));
2574 let content = &body.content[0];
2575 assert!(content.extensions.iter().any(|(k, _)| k == "x-content"));
2576 let (_enc_name, enc) = &content.encoding[0];
2577 assert!(enc.extensions.iter().any(|(k, _)| k == "x-encoding"));
2578 let resp = &op.responses[0];
2579 assert!(resp.extensions.iter().any(|(k, _)| k == "x-response"));
2580
2581 let scheme = ir
2583 .security_schemes
2584 .iter()
2585 .find(|s| s.id == "OAuth")
2586 .unwrap();
2587 assert!(scheme.extensions.iter().any(|(k, _)| k == "x-scheme"));
2588 let SecuritySchemeKind::Oauth2(o) = &scheme.kind else {
2589 panic!("expected oauth2");
2590 };
2591 let flow = &o.flows[0];
2592 assert!(flow.extensions.iter().any(|(k, _)| k == "x-flow"));
2593 }
2594
2595 #[test]
2596 fn compound_extensions_survive_via_value_pool() {
2597 let src = r#"{
2601 "openapi":"3.0.3",
2602 "info":{
2603 "title":"t",
2604 "version":"1",
2605 "x-array":[1,2,3]
2606 },
2607 "paths":{}
2608 }"#;
2609 let ir = parse_str(src).unwrap().spec.unwrap();
2610 let entry = ir
2611 .info
2612 .extensions
2613 .iter()
2614 .find(|(k, _)| k == "x-array")
2615 .expect("x-array extension survives");
2616 let r = entry.1 as usize;
2617 let forge_ir::Value::List { items } = &ir.values[r] else {
2618 panic!("expected list, got {:?}", ir.values[r]);
2619 };
2620 assert_eq!(items.len(), 3);
2621 }
2622
2623 #[test]
2624 fn server_name_3_2_round_trips() {
2625 let src = r#"{
2628 "openapi":"3.2.0",
2629 "info":{"title":"t","version":"1"},
2630 "servers":[
2631 {"url":"https://api.example.com","name":"production"},
2632 {"url":"https://staging.example.com"}
2633 ],
2634 "paths":{}
2635 }"#;
2636 let ir = parse_str(src).unwrap().spec.unwrap();
2637 assert_eq!(ir.servers[0].name.as_deref(), Some("production"));
2638 assert!(ir.servers[1].name.is_none());
2639 }
2640
2641 #[test]
2642 fn parameter_querystring_3_2_routes_to_new_bucket() {
2643 let src = r#"{
2647 "openapi":"3.2.0",
2648 "info":{"title":"t","version":"1"},
2649 "paths":{
2650 "/search":{"get":{
2651 "operationId":"search",
2652 "parameters":[
2653 {"name":"raw","in":"querystring","schema":{"type":"string"}}
2654 ],
2655 "responses":{"200":{"description":"ok"}}
2656 }}
2657 }
2658 }"#;
2659 let ir = parse_str(src).unwrap().spec.unwrap();
2660 let op = &ir.operations[0];
2661 assert!(op.query_params.is_empty(), "must not land in query_params");
2662 assert_eq!(op.querystring_params.len(), 1);
2663 assert_eq!(op.querystring_params[0].name, "raw");
2664 }
2665
2666 #[test]
2667 fn example_data_value_serialized_value_3_2() {
2668 let src = r##"{
2671 "openapi":"3.2.0",
2672 "info":{"title":"t","version":"1"},
2673 "paths":{},
2674 "components":{
2675 "schemas":{
2676 "Thing":{
2677 "type":"string",
2678 "examples":[
2679 {"summary":"alice","dataValue":"alice","serializedValue":"\"alice\""}
2680 ]
2681 }
2682 }
2683 }
2684 }"##;
2685 let src2 = r##"{
2688 "openapi":"3.2.0",
2689 "info":{"title":"t","version":"1"},
2690 "paths":{
2691 "/thing":{"post":{
2692 "operationId":"create",
2693 "requestBody":{"content":{"application/json":{
2694 "schema":{"type":"string"},
2695 "examples":{
2696 "alice":{"dataValue":"alice","serializedValue":"\"alice\""}
2697 }
2698 }}},
2699 "responses":{"200":{"description":"ok"}}
2700 }}
2701 }
2702 }"##;
2703 let _ = src; let ir = parse_str(src2).unwrap().spec.unwrap();
2705 let body = ir.operations[0].request_body.as_ref().unwrap();
2706 let example = &body.content[0].examples[0].1;
2707 let r = example.data_value.unwrap() as usize;
2708 assert_eq!(ir.values[r], forge_ir::Value::s("alice"));
2709 assert_eq!(example.serialized_value.as_deref(), Some("\"alice\""));
2710 }
2711
2712 #[test]
2713 fn xml_text_ordered_3_2() {
2714 let src = r#"{
2716 "openapi":"3.2.0",
2717 "info":{"title":"t","version":"1"},
2718 "paths":{},
2719 "components":{
2720 "schemas":{
2721 "Title":{"type":"string","xml":{"text":true}},
2722 "Steps":{"type":"array","items":{"type":"string"},"xml":{"wrapped":true,"ordered":true}}
2723 }
2724 }
2725 }"#;
2726 let ir = parse_str(src).unwrap().spec.unwrap();
2727 let title = ir.types.iter().find(|t| t.id == "Title").unwrap();
2728 let title_xml = title.xml.as_ref().unwrap();
2729 assert!(title_xml.text);
2730 assert!(!title_xml.ordered);
2731
2732 let steps = ir.types.iter().find(|t| t.id == "Steps").unwrap();
2733 let steps_xml = steps.xml.as_ref().unwrap();
2734 assert!(steps_xml.ordered);
2735 assert!(!steps_xml.text);
2736 }
2737
2738 #[test]
2739 fn mutual_tls_security_scheme_round_trips() {
2740 use forge_ir::SecuritySchemeKind;
2741 let src = r#"{
2742 "openapi":"3.0.3",
2743 "info":{"title":"t","version":"1"},
2744 "paths":{},
2745 "components":{"securitySchemes":{
2746 "mtls":{"type":"mutualTLS","description":"client-cert auth"}
2747 }}
2748 }"#;
2749 let ir = parse_str(src).unwrap().spec.unwrap();
2750 let scheme = &ir.security_schemes[0];
2751 assert_eq!(scheme.id, "mtls");
2752 assert!(matches!(scheme.kind, SecuritySchemeKind::MutualTls));
2753 assert_eq!(scheme.description.as_deref(), Some("client-cert auth"));
2754 }
2755
2756 #[test]
2757 fn oauth2_all_four_flows_succeed() {
2758 use forge_ir::{OAuth2FlowKind, SecuritySchemeKind};
2759 let src = r#"{
2760 "openapi":"3.0.3",
2761 "info":{"title":"t","version":"1"},
2762 "paths":{},
2763 "components":{"securitySchemes":{
2764 "auth":{"type":"oauth2","flows":{
2765 "implicit":{
2766 "authorizationUrl":"https://a/auth",
2767 "scopes":{"read":"r"}
2768 },
2769 "password":{
2770 "tokenUrl":"https://a/token",
2771 "scopes":{"read":"r"}
2772 },
2773 "clientCredentials":{
2774 "tokenUrl":"https://a/token",
2775 "scopes":{"read":"r"}
2776 },
2777 "authorizationCode":{
2778 "authorizationUrl":"https://a/auth",
2779 "tokenUrl":"https://a/token",
2780 "scopes":{"read":"r"}
2781 }
2782 }}
2783 }}
2784 }"#;
2785 let ir = parse_str(src).unwrap().spec.unwrap();
2786 let scheme = &ir.security_schemes[0];
2787 let SecuritySchemeKind::Oauth2(o) = &scheme.kind else {
2788 panic!("expected oauth2 kind");
2789 };
2790 assert_eq!(o.flows.len(), 4, "all four flows surface");
2791 let kinds: Vec<OAuth2FlowKind> = o.flows.iter().map(|f| f.kind).collect();
2792 assert!(kinds.contains(&OAuth2FlowKind::Implicit));
2793 assert!(kinds.contains(&OAuth2FlowKind::Password));
2794 assert!(kinds.contains(&OAuth2FlowKind::ClientCredentials));
2795 assert!(kinds.contains(&OAuth2FlowKind::AuthorizationCode));
2796 }
2797
2798 #[test]
2799 fn oauth2_missing_required_url_errors() {
2800 let src = r#"{
2803 "openapi":"3.0.3",
2804 "info":{"title":"t","version":"1"},
2805 "paths":{},
2806 "components":{"securitySchemes":{
2807 "auth":{"type":"oauth2","flows":{
2808 "password":{"scopes":{"read":"r"}}
2809 }}
2810 }}
2811 }"#;
2812 let out = parse_str(src).unwrap();
2813 assert!(
2814 out.diagnostics
2815 .iter()
2816 .any(|d| d.code == diag::E_OAUTH2_MISSING_URL),
2817 "expected E-OAUTH2-MISSING-URL"
2818 );
2819 }
2820
2821 #[test]
2822 fn content_encoding_keywords_round_trip() {
2823 use forge_ir::TypeDef;
2828 let src = r#"{
2829 "openapi":"3.2.0",
2830 "info":{"title":"t","version":"1"},
2831 "paths":{},
2832 "components":{"schemas":{
2833 "Avatar":{
2834 "type":"string",
2835 "contentEncoding":"base64",
2836 "contentMediaType":"image/png"
2837 },
2838 "Embedded":{
2839 "type":"string",
2840 "contentMediaType":"application/json",
2841 "contentSchema":{
2842 "type":"object",
2843 "properties":{"id":{"type":"string"}}
2844 }
2845 }
2846 }}
2847 }"#;
2848 let ir = parse_str(src).unwrap().spec.unwrap();
2849
2850 let avatar = ir.types.iter().find(|t| t.id == "Avatar").unwrap();
2851 let TypeDef::Primitive(p) = &avatar.definition else {
2852 panic!("expected primitive");
2853 };
2854 assert_eq!(p.constraints.content_encoding.as_deref(), Some("base64"));
2855 assert_eq!(
2856 p.constraints.content_media_type.as_deref(),
2857 Some("image/png")
2858 );
2859 assert!(p.constraints.content_schema.is_none());
2860
2861 let embedded = ir.types.iter().find(|t| t.id == "Embedded").unwrap();
2862 let TypeDef::Primitive(p) = &embedded.definition else {
2863 panic!("expected primitive");
2864 };
2865 assert_eq!(
2866 p.constraints.content_media_type.as_deref(),
2867 Some("application/json")
2868 );
2869 let cs_ref = p
2870 .constraints
2871 .content_schema
2872 .as_deref()
2873 .expect("content_schema set");
2874 assert!(ir.types.iter().any(|t| t.id == cs_ref));
2876 }
2877
2878 #[test]
2879 fn components_media_types_pool_resolves_refs() {
2880 let src = r##"{
2884 "openapi":"3.2.0",
2885 "info":{"title":"t","version":"1"},
2886 "paths":{
2887 "/things":{"post":{
2888 "operationId":"create",
2889 "requestBody":{"content":{
2890 "application/json":{"$ref":"#/components/mediaTypes/ThingJson"}
2891 }},
2892 "responses":{"204":{"description":"ok"}}
2893 }}
2894 },
2895 "components":{
2896 "schemas":{
2897 "Thing":{"type":"object","properties":{"id":{"type":"string"}}}
2898 },
2899 "mediaTypes":{
2900 "ThingJson":{"schema":{"$ref":"#/components/schemas/Thing"}},
2901 "Unused":{"schema":{"type":"string"}}
2902 }
2903 }
2904 }"##;
2905 let out = parse_str(src).unwrap();
2906 let ir = out.spec.unwrap();
2907 let body = ir.operations[0].request_body.as_ref().unwrap();
2908 assert_eq!(body.content[0].r#type, "Thing");
2910 assert!(
2912 out.diagnostics
2913 .iter()
2914 .any(|d| d.code == diag::W_COMPONENT_MEDIA_TYPE_UNUSED
2915 && d.message.contains("Unused")),
2916 "expected W-COMPONENT-MEDIA-TYPE-UNUSED for `Unused`"
2917 );
2918 assert!(
2920 !out.diagnostics
2921 .iter()
2922 .any(|d| d.code == diag::W_COMPONENT_MEDIA_TYPE_UNUSED
2923 && d.message.contains("ThingJson")),
2924 "ThingJson is referenced; should not warn"
2925 );
2926 }
2927
2928 #[test]
2929 fn json_schema_deferred_keywords_warn_not_error() {
2930 let src = r#"{
2935 "openapi":"3.1.0",
2936 "info":{"title":"t","version":"1"},
2937 "paths":{},
2938 "components":{"schemas":{
2939 "Bad":{
2940 "type":"object",
2941 "dependentRequired":{"a":["b"]},
2942 "unevaluatedProperties":false,
2943 "properties":{"id":{"type":"string"}}
2944 }
2945 }}
2946 }"#;
2947 let out = parse_str(src).unwrap();
2948 let ir = out.spec.expect("spec parses despite deferred keywords");
2949 assert!(ir.types.iter().any(|t| t.id == "Bad"));
2952 let warns: Vec<&str> = out
2954 .diagnostics
2955 .iter()
2956 .filter(|d| d.severity == forge_ir::Severity::Warning)
2957 .map(|d| d.code.as_str())
2958 .collect();
2959 assert!(
2960 warns.contains(&diag::W_DEPENDENT_REQUIRED_DROPPED),
2961 "expected W-DEPENDENT-REQUIRED-DROPPED, got {warns:?}"
2962 );
2963 assert!(
2964 warns.contains(&diag::W_UNEVALUATED_PROPERTIES_DROPPED),
2965 "expected W-UNEVALUATED-PROPERTIES-DROPPED, got {warns:?}"
2966 );
2967 let errs: Vec<&str> = out
2969 .diagnostics
2970 .iter()
2971 .filter(|d| d.severity == forge_ir::Severity::Error)
2972 .map(|d| d.code.as_str())
2973 .collect();
2974 assert!(errs.is_empty(), "no errors expected, got {errs:?}");
2975 }
2976
2977 #[test]
2978 fn root_json_schema_dialect_and_self_round_trip() {
2979 let src = r##"{
2981 "openapi":"3.2.0",
2982 "$self":"https://example.com/api.json",
2983 "jsonSchemaDialect":"https://json-schema.org/draft/2020-12/schema",
2984 "info":{"title":"t","version":"1"},
2985 "paths":{}
2986 }"##;
2987 let ir = parse_str(src).unwrap().spec.unwrap();
2988 assert_eq!(
2989 ir.json_schema_dialect.as_deref(),
2990 Some("https://json-schema.org/draft/2020-12/schema")
2991 );
2992 assert_eq!(ir.self_url.as_deref(), Some("https://example.com/api.json"));
2993 }
2994
2995 #[test]
2996 fn header_style_explode_round_trip() {
2997 use forge_ir::ParameterStyle;
3000 let src = r#"{
3001 "openapi":"3.0.3",
3002 "info":{"title":"t","version":"1"},
3003 "paths":{"/x":{"get":{
3004 "operationId":"x",
3005 "responses":{"200":{
3006 "description":"ok",
3007 "headers":{
3008 "X-Rate":{
3009 "schema":{"type":"integer"},
3010 "style":"simple",
3011 "explode":true,
3012 "allowReserved":false,
3013 "allowEmptyValue":false
3014 }
3015 }
3016 }}
3017 }}}
3018 }"#;
3019 let ir = parse_str(src).unwrap().spec.unwrap();
3020 let resp = &ir.operations[0].responses[0];
3021 let (_name, h) = &resp.headers[0];
3022 assert_eq!(h.style, Some(ParameterStyle::Simple));
3023 assert!(h.explode);
3024 assert!(!h.allow_reserved);
3025 assert!(!h.allow_empty_value);
3026 }
3027
3028 #[test]
3029 fn ref_siblings_3_1_plus_merge_onto_target() {
3030 let src = r##"{
3034 "openapi":"3.2.0",
3035 "info":{"title":"t","version":"1"},
3036 "paths":{"/x":{"get":{
3037 "operationId":"x",
3038 "responses":{"200":{
3039 "$ref":"#/components/responses/Shared",
3040 "description":"per-call override"
3041 }}
3042 }}},
3043 "components":{"responses":{
3044 "Shared":{
3045 "description":"shared default",
3046 "content":{"application/json":{"schema":{"type":"string"}}}
3047 }
3048 }}
3049 }"##;
3050 let ir = parse_str(src).unwrap().spec.unwrap();
3051 let resp = &ir.operations[0].responses[0];
3052 assert_eq!(
3053 resp.description.as_deref(),
3054 Some("per-call override"),
3055 "the sibling `description` wins over the shared default"
3056 );
3057 }
3058
3059 #[test]
3063 fn ref_summary_override_on_response_3_2() {
3064 let src = r##"{
3065 "openapi":"3.2.0",
3066 "info":{"title":"t","version":"1"},
3067 "paths":{"/x":{"get":{
3068 "operationId":"x",
3069 "responses":{"200":{
3070 "$ref":"#/components/responses/Shared",
3071 "summary":"per-call summary",
3072 "description":"per-call desc"
3073 }}
3074 }}},
3075 "components":{"responses":{
3076 "Shared":{
3077 "summary":"shared summary",
3078 "description":"shared desc"
3079 }
3080 }}
3081 }"##;
3082 let ir = parse_str(src).unwrap().spec.unwrap();
3083 let resp = &ir.operations[0].responses[0];
3084 assert_eq!(resp.summary.as_deref(), Some("per-call summary"));
3085 assert_eq!(resp.description.as_deref(), Some("per-call desc"));
3086 }
3087
3088 #[test]
3092 fn ref_override_on_parameter_summary_has_no_effect() {
3093 let src = r##"{
3094 "openapi":"3.2.0",
3095 "info":{"title":"t","version":"1"},
3096 "paths":{"/x/{id}":{
3097 "parameters":[{
3098 "$ref":"#/components/parameters/Id",
3099 "summary":"override summary",
3100 "description":"override desc"
3101 }],
3102 "get":{"operationId":"x","responses":{"200":{"description":"ok"}}}
3103 }},
3104 "components":{"parameters":{
3105 "Id":{"name":"id","in":"path","required":true,"schema":{"type":"string"},
3106 "description":"shared desc"}
3107 }}
3108 }"##;
3109 let ir = parse_str(src).unwrap().spec.unwrap();
3110 let param = &ir.operations[0].path_params[0];
3111 assert_eq!(param.description.as_deref(), Some("override desc"));
3113 }
3118
3119 #[test]
3122 fn ref_description_override_on_header() {
3123 let src = r##"{
3124 "openapi":"3.2.0",
3125 "info":{"title":"t","version":"1"},
3126 "paths":{"/x":{"get":{
3127 "operationId":"x",
3128 "responses":{"200":{
3129 "description":"ok",
3130 "headers":{
3131 "X-Trace":{
3132 "$ref":"#/components/headers/Trace",
3133 "description":"override"
3134 }
3135 }
3136 }}
3137 }}},
3138 "components":{"headers":{
3139 "Trace":{"schema":{"type":"string"},"description":"shared"}
3140 }}
3141 }"##;
3142 let ir = parse_str(src).unwrap().spec.unwrap();
3143 let (_n, h) = &ir.operations[0].responses[0].headers[0];
3144 assert_eq!(h.description.as_deref(), Some("override"));
3145 }
3146
3147 #[test]
3150 fn ref_override_on_example_summary_and_description() {
3151 let src = r##"{
3152 "openapi":"3.2.0",
3153 "info":{"title":"t","version":"1"},
3154 "paths":{},
3155 "components":{
3156 "examples":{
3157 "Shared":{"summary":"shared sum","description":"shared d","value":"v"}
3158 },
3159 "schemas":{
3160 "Foo":{
3161 "type":"string",
3162 "examples":{
3163 "ref":{
3164 "$ref":"#/components/examples/Shared",
3165 "summary":"call sum",
3166 "description":"call d"
3167 }
3168 }
3169 }
3170 }
3171 }
3172 }"##;
3173 let ir = parse_str(src).unwrap().spec.unwrap();
3174 let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
3175 let ex = &foo.examples[0].1;
3176 assert_eq!(ex.summary.as_deref(), Some("call sum"));
3177 assert_eq!(ex.description.as_deref(), Some("call d"));
3178 }
3179
3180 #[test]
3184 fn ref_description_override_on_link() {
3185 let src = r##"{
3186 "openapi":"3.2.0",
3187 "info":{"title":"t","version":"1"},
3188 "paths":{"/x":{"get":{
3189 "operationId":"x",
3190 "responses":{"200":{
3191 "description":"ok",
3192 "links":{"next":{
3193 "$ref":"#/components/links/Shared",
3194 "description":"per-call link doc"
3195 }}
3196 }}
3197 }}},
3198 "components":{"links":{
3199 "Shared":{"operationId":"x","description":"shared link doc"}
3200 }}
3201 }"##;
3202 let ir = parse_str(src).unwrap().spec.unwrap();
3203 let link = &ir.operations[0].responses[0].links[0].1;
3204 assert_eq!(link.description.as_deref(), Some("per-call link doc"));
3205 }
3206
3207 #[test]
3210 fn ref_description_override_on_request_body() {
3211 let src = r##"{
3212 "openapi":"3.2.0",
3213 "info":{"title":"t","version":"1"},
3214 "paths":{"/x":{"post":{
3215 "operationId":"x",
3216 "requestBody":{
3217 "$ref":"#/components/requestBodies/Shared",
3218 "description":"per-call"
3219 },
3220 "responses":{"200":{"description":"ok"}}
3221 }}},
3222 "components":{"requestBodies":{
3223 "Shared":{
3224 "description":"shared",
3225 "content":{"application/json":{"schema":{"type":"string"}}}
3226 }
3227 }}
3228 }"##;
3229 let ir = parse_str(src).unwrap().spec.unwrap();
3230 let body = ir.operations[0].request_body.as_ref().unwrap();
3231 assert_eq!(body.description.as_deref(), Some("per-call"));
3232 }
3233
3234 #[test]
3237 fn ref_description_override_on_security_scheme() {
3238 let src = r##"{
3239 "openapi":"3.2.0",
3240 "info":{"title":"t","version":"1"},
3241 "paths":{},
3242 "components":{"securitySchemes":{
3243 "Wrap":{
3244 "$ref":"#/components/securitySchemes/Shared",
3245 "description":"per-call"
3246 },
3247 "Shared":{"type":"http","scheme":"bearer","description":"shared"}
3248 }}
3249 }"##;
3250 let ir = parse_str(src).unwrap().spec.unwrap();
3251 let scheme = ir
3252 .security_schemes
3253 .iter()
3254 .find(|s| s.id == "Wrap")
3255 .expect("Wrap scheme present");
3256 assert_eq!(scheme.description.as_deref(), Some("per-call"));
3257 }
3258
3259 #[test]
3264 fn ref_with_invalid_siblings_warns_and_drops() {
3265 let src = r##"{
3266 "openapi":"3.2.0",
3267 "info":{"title":"t","version":"1"},
3268 "paths":{"/x":{"get":{
3269 "operationId":"x",
3270 "parameters":[{
3271 "$ref":"#/components/parameters/Id",
3272 "description":"ok",
3273 "required":false,
3274 "deprecated":true
3275 }],
3276 "responses":{"200":{"description":"ok"}}
3277 }}},
3278 "components":{"parameters":{
3279 "Id":{"name":"id","in":"path","required":true,"schema":{"type":"string"}}
3280 }}
3281 }"##;
3282 let out = parse_str(src).unwrap();
3283 let ir = out.spec.as_ref().unwrap();
3284 let param = &ir.operations[0].path_params[0];
3285 assert!(param.required);
3288 assert!(!param.deprecated);
3289 assert_eq!(param.description.as_deref(), Some("ok"));
3291 assert!(
3292 out.diagnostics
3293 .iter()
3294 .any(|d| d.code == diag::W_REF_SIBLINGS_INVALID),
3295 "expected W-REF-SIBLINGS-INVALID; got: {:?}",
3296 out.diagnostics.iter().map(|d| &d.code).collect::<Vec<_>>()
3297 );
3298 }
3299
3300 #[test]
3304 fn ref_with_x_extension_sibling_warns() {
3305 let src = r##"{
3306 "openapi":"3.2.0",
3307 "info":{"title":"t","version":"1"},
3308 "paths":{"/x":{"get":{
3309 "operationId":"x",
3310 "responses":{"200":{
3311 "$ref":"#/components/responses/Shared",
3312 "x-vendor":"yes"
3313 }}
3314 }}},
3315 "components":{"responses":{
3316 "Shared":{"description":"shared"}
3317 }}
3318 }"##;
3319 let out = parse_str(src).unwrap();
3320 assert!(
3321 out.diagnostics
3322 .iter()
3323 .any(|d| d.code == diag::W_REF_SIBLINGS_INVALID),
3324 "expected W-REF-SIBLINGS-INVALID; got: {:?}",
3325 out.diagnostics.iter().map(|d| &d.code).collect::<Vec<_>>()
3326 );
3327 }
3328
3329 #[test]
3334 fn ref_override_on_media_type_has_no_effect() {
3335 let src = r##"{
3336 "openapi":"3.2.0",
3337 "info":{"title":"t","version":"1"},
3338 "paths":{"/x":{"get":{
3339 "operationId":"x",
3340 "responses":{"200":{
3341 "description":"ok",
3342 "content":{"application/json":{
3343 "$ref":"#/components/mediaTypes/Shared",
3344 "description":"ignored"
3345 }}
3346 }}
3347 }}},
3348 "components":{"mediaTypes":{
3349 "Shared":{"schema":{"type":"string"}}
3350 }}
3351 }"##;
3352 let out = parse_str(src).unwrap();
3353 let ir = out.spec.as_ref().unwrap();
3354 let content = &ir.operations[0].responses[0].content[0];
3355 assert_eq!(content.media_type, "application/json");
3360 assert!(content.examples.is_empty());
3361 }
3362
3363 #[test]
3368 fn ref_chain_only_outermost_siblings_apply() {
3369 let src = r##"{
3370 "openapi":"3.2.0",
3371 "info":{"title":"t","version":"1"},
3372 "paths":{"/x":{"get":{
3373 "operationId":"x",
3374 "responses":{"200":{
3375 "$ref":"#/components/responses/Outer",
3376 "description":"call-site"
3377 }}
3378 }}},
3379 "components":{"responses":{
3380 "Outer":{"$ref":"#/components/responses/Inner"},
3381 "Inner":{"description":"deepest"}
3382 }}
3383 }"##;
3384 let out = parse_str(src).unwrap();
3385 let resp = &out.spec.as_ref().unwrap().operations[0].responses[0];
3386 assert_eq!(resp.description.as_deref(), Some("call-site"));
3387 }
3388
3389 #[test]
3393 fn ref_override_skipped_for_oas_3_0() {
3394 let src = r##"{
3395 "openapi":"3.0.3",
3396 "info":{"title":"t","version":"1"},
3397 "paths":{"/x":{"get":{
3398 "operationId":"x",
3399 "responses":{"200":{
3400 "$ref":"#/components/responses/Shared",
3401 "description":"would-be override"
3402 }}
3403 }}},
3404 "components":{"responses":{
3405 "Shared":{"description":"shared"}
3406 }}
3407 }"##;
3408 let ir = parse_str(src).unwrap().spec.unwrap();
3409 let resp = &ir.operations[0].responses[0];
3410 assert_eq!(resp.description.as_deref(), Some("shared"));
3412 }
3413
3414 #[test]
3417 fn ref_with_only_dollar_ref_no_overrides() {
3418 let src = r##"{
3419 "openapi":"3.2.0",
3420 "info":{"title":"t","version":"1"},
3421 "paths":{"/x":{"get":{
3422 "operationId":"x",
3423 "responses":{"200":{"$ref":"#/components/responses/Shared"}}
3424 }}},
3425 "components":{"responses":{
3426 "Shared":{"description":"shared","summary":"shared sum"}
3427 }}
3428 }"##;
3429 let out = parse_str(src).unwrap();
3430 let resp = &out.spec.as_ref().unwrap().operations[0].responses[0];
3431 assert_eq!(resp.summary.as_deref(), Some("shared sum"));
3432 assert_eq!(resp.description.as_deref(), Some("shared"));
3433 assert!(
3434 !out.diagnostics
3435 .iter()
3436 .any(|d| d.code == diag::W_REF_SIBLINGS_INVALID),
3437 "no W-REF-SIBLINGS-INVALID for a bare $ref"
3438 );
3439 }
3440
3441 #[test]
3444 fn ref_summary_override_only_leaves_description_intact() {
3445 let src = r##"{
3446 "openapi":"3.2.0",
3447 "info":{"title":"t","version":"1"},
3448 "paths":{"/x":{"get":{
3449 "operationId":"x",
3450 "responses":{"200":{
3451 "$ref":"#/components/responses/Shared",
3452 "summary":"call-site sum"
3453 }}
3454 }}},
3455 "components":{"responses":{
3456 "Shared":{"summary":"shared sum","description":"shared d"}
3457 }}
3458 }"##;
3459 let resp = &parse_str(src).unwrap().spec.unwrap().operations[0].responses[0];
3460 assert_eq!(resp.summary.as_deref(), Some("call-site sum"));
3461 assert_eq!(resp.description.as_deref(), Some("shared d"));
3462 }
3463
3464 #[test]
3467 fn ref_description_override_only_leaves_summary_intact() {
3468 let src = r##"{
3469 "openapi":"3.2.0",
3470 "info":{"title":"t","version":"1"},
3471 "paths":{"/x":{"get":{
3472 "operationId":"x",
3473 "responses":{"200":{
3474 "$ref":"#/components/responses/Shared",
3475 "description":"call-site d"
3476 }}
3477 }}},
3478 "components":{"responses":{
3479 "Shared":{"summary":"shared sum","description":"shared d"}
3480 }}
3481 }"##;
3482 let resp = &parse_str(src).unwrap().spec.unwrap().operations[0].responses[0];
3483 assert_eq!(resp.summary.as_deref(), Some("shared sum"));
3484 assert_eq!(resp.description.as_deref(), Some("call-site d"));
3485 }
3486
3487 #[test]
3493 fn operation_summary_independent_of_description() {
3494 let src = r#"{
3495 "openapi":"3.2.0",
3496 "info":{"title":"t","version":"1"},
3497 "paths":{"/x":{"get":{
3498 "operationId":"x",
3499 "summary":"short",
3500 "description":"long form",
3501 "responses":{"200":{"description":"ok"}}
3502 }}}
3503 }"#;
3504 let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3505 assert_eq!(op.summary.as_deref(), Some("short"));
3506 assert_eq!(op.description.as_deref(), Some("long form"));
3507 }
3508
3509 #[test]
3512 fn operation_summary_falls_back_from_path_item() {
3513 let src = r#"{
3514 "openapi":"3.2.0",
3515 "info":{"title":"t","version":"1"},
3516 "paths":{"/x":{
3517 "summary":"path-item sum",
3518 "description":"path-item desc",
3519 "get":{"operationId":"x","responses":{"200":{"description":"ok"}}}
3520 }}
3521 }"#;
3522 let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3523 assert_eq!(op.summary.as_deref(), Some("path-item sum"));
3524 assert_eq!(op.description.as_deref(), Some("path-item desc"));
3525 }
3526
3527 #[test]
3531 fn path_item_fallback_per_field_independent() {
3532 let src = r#"{
3533 "openapi":"3.2.0",
3534 "info":{"title":"t","version":"1"},
3535 "paths":{"/x":{
3536 "summary":"path-item sum",
3537 "description":"path-item desc",
3538 "get":{
3539 "operationId":"x",
3540 "summary":"op sum",
3541 "responses":{"200":{"description":"ok"}}
3542 }
3543 }}
3544 }"#;
3545 let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3546 assert_eq!(op.summary.as_deref(), Some("op sum"));
3547 assert_eq!(op.description.as_deref(), Some("path-item desc"));
3548 }
3549
3550 #[test]
3552 fn operation_deprecated_populates() {
3553 let src = r#"{
3554 "openapi":"3.2.0",
3555 "info":{"title":"t","version":"1"},
3556 "paths":{"/x":{"get":{
3557 "operationId":"x",
3558 "deprecated":true,
3559 "responses":{"200":{"description":"ok"}}
3560 }}}
3561 }"#;
3562 let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3563 assert!(op.deprecated);
3564 }
3565
3566 #[test]
3569 fn webhook_path_item_summary_and_description_populate() {
3570 let src = r#"{
3571 "openapi":"3.2.0",
3572 "info":{"title":"t","version":"1"},
3573 "paths":{},
3574 "webhooks":{
3575 "newPet":{
3576 "summary":"hook sum",
3577 "description":"hook desc",
3578 "post":{"operationId":"onNewPet","responses":{"200":{"description":"ok"}}}
3579 }
3580 }
3581 }"#;
3582 let ir = parse_str(src).unwrap().spec.unwrap();
3583 let webhook = ir.webhooks.iter().find(|w| w.name == "newPet").unwrap();
3584 assert_eq!(webhook.summary.as_deref(), Some("hook sum"));
3585 assert_eq!(webhook.description.as_deref(), Some("hook desc"));
3586 }
3587
3588 #[test]
3591 fn response_summary_3_2_populates() {
3592 let src = r#"{
3593 "openapi":"3.2.0",
3594 "info":{"title":"t","version":"1"},
3595 "paths":{"/x":{"get":{
3596 "operationId":"x",
3597 "responses":{"200":{
3598 "summary":"short label",
3599 "description":"long form"
3600 }}
3601 }}}
3602 }"#;
3603 let resp = &parse_str(src).unwrap().spec.unwrap().operations[0].responses[0];
3604 assert_eq!(resp.summary.as_deref(), Some("short label"));
3605 assert_eq!(resp.description.as_deref(), Some("long form"));
3606 }
3607
3608 #[test]
3611 fn property_title_populates() {
3612 let src = r##"{
3613 "openapi":"3.2.0",
3614 "info":{"title":"t","version":"1"},
3615 "paths":{},
3616 "components":{"schemas":{
3617 "User":{
3618 "type":"object",
3619 "properties":{
3620 "id":{"type":"string","title":"User ID"}
3621 }
3622 }
3623 }}
3624 }"##;
3625 let ir = parse_str(src).unwrap().spec.unwrap();
3626 let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3627 let forge_ir::TypeDef::Object(obj) = &user.definition else {
3628 panic!("expected object");
3629 };
3630 let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3631 assert_eq!(id_prop.title.as_deref(), Some("User ID"));
3632 }
3633
3634 #[test]
3637 fn property_external_docs_populates() {
3638 let src = r##"{
3639 "openapi":"3.2.0",
3640 "info":{"title":"t","version":"1"},
3641 "paths":{},
3642 "components":{"schemas":{
3643 "User":{
3644 "type":"object",
3645 "properties":{
3646 "id":{
3647 "type":"string",
3648 "externalDocs":{"url":"https://example.com","description":"d"}
3649 }
3650 }
3651 }
3652 }}
3653 }"##;
3654 let ir = parse_str(src).unwrap().spec.unwrap();
3655 let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3656 let forge_ir::TypeDef::Object(obj) = &user.definition else {
3657 panic!("expected object");
3658 };
3659 let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3660 let ed = id_prop.external_docs.as_ref().unwrap();
3661 assert_eq!(ed.url, "https://example.com");
3662 assert_eq!(ed.description.as_deref(), Some("d"));
3663 }
3664
3665 #[test]
3668 fn property_examples_populates() {
3669 let src = r##"{
3670 "openapi":"3.2.0",
3671 "info":{"title":"t","version":"1"},
3672 "paths":{},
3673 "components":{"schemas":{
3674 "User":{
3675 "type":"object",
3676 "properties":{
3677 "id":{"type":"string","example":"42"}
3678 }
3679 }
3680 }}
3681 }"##;
3682 let ir = parse_str(src).unwrap().spec.unwrap();
3683 let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3684 let forge_ir::TypeDef::Object(obj) = &user.definition else {
3685 panic!("expected object");
3686 };
3687 let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3688 assert_eq!(id_prop.examples.len(), 1);
3689 assert_eq!(id_prop.examples[0].0, "_default");
3690 }
3691
3692 #[test]
3695 fn property_deprecated_populates() {
3696 let src = r##"{
3697 "openapi":"3.2.0",
3698 "info":{"title":"t","version":"1"},
3699 "paths":{},
3700 "components":{"schemas":{
3701 "User":{
3702 "type":"object",
3703 "properties":{
3704 "id":{"type":"string","deprecated":true}
3705 }
3706 }
3707 }}
3708 }"##;
3709 let ir = parse_str(src).unwrap().spec.unwrap();
3710 let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3711 let forge_ir::TypeDef::Object(obj) = &user.definition else {
3712 panic!("expected object");
3713 };
3714 let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3715 assert!(id_prop.deprecated);
3716 }
3717
3718 #[test]
3721 fn named_type_deprecated_populates() {
3722 let src = r##"{
3723 "openapi":"3.2.0",
3724 "info":{"title":"t","version":"1"},
3725 "paths":{},
3726 "components":{"schemas":{
3727 "Legacy":{"type":"string","deprecated":true}
3728 }}
3729 }"##;
3730 let ir = parse_str(src).unwrap().spec.unwrap();
3731 let legacy = ir.types.iter().find(|t| t.id == "Legacy").unwrap();
3732 assert!(legacy.deprecated);
3733 }
3734
3735 #[test]
3738 fn security_scheme_deprecated_3_2_populates() {
3739 let src = r##"{
3740 "openapi":"3.2.0",
3741 "info":{"title":"t","version":"1"},
3742 "paths":{},
3743 "components":{"securitySchemes":{
3744 "Old":{
3745 "type":"http",
3746 "scheme":"basic",
3747 "description":"do not use",
3748 "deprecated":true
3749 }
3750 }}
3751 }"##;
3752 let ir = parse_str(src).unwrap().spec.unwrap();
3753 let scheme = ir.security_schemes.iter().find(|s| s.id == "Old").unwrap();
3754 assert!(scheme.deprecated);
3755 assert_eq!(scheme.description.as_deref(), Some("do not use"));
3756 }
3757
3758 #[test]
3761 fn named_type_title_populates() {
3762 let src = r##"{
3763 "openapi":"3.2.0",
3764 "info":{"title":"t","version":"1"},
3765 "paths":{},
3766 "components":{"schemas":{
3767 "Foo":{"type":"string","title":"Foo Type","description":"long"}
3768 }}
3769 }"##;
3770 let ir = parse_str(src).unwrap().spec.unwrap();
3771 let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
3772 assert_eq!(foo.title.as_deref(), Some("Foo Type"));
3773 assert_eq!(foo.description.as_deref(), Some("long"));
3774 }
3775
3776 #[test]
3777 fn primitive_kind_carries_only_jsonschema_type_values() {
3778 use forge_ir::{PrimitiveKind, TypeDef};
3783 let src = r#"{
3784 "openapi":"3.0.3",
3785 "info":{"title":"t","version":"1"},
3786 "paths":{},
3787 "components":{"schemas":{
3788 "Plain": {"type":"string"},
3789 "Stamp": {"type":"string","format":"date-time"},
3790 "Mail": {"type":"string","format":"email"},
3791 "Avatar": {"type":"string","format":"byte"},
3792 "Tally": {"type":"integer","format":"int32"},
3793 "Big": {"type":"integer","format":"int64"},
3794 "Money": {"type":"string","format":"decimal"},
3795 "Flag": {"type":"boolean"}
3796 }}
3797 }"#;
3798 let ir = parse_str(src).unwrap().spec.unwrap();
3799 let prim = |id: &str| -> (PrimitiveKind, Option<String>) {
3800 let nt = ir.types.iter().find(|t| t.id == id).unwrap();
3801 let TypeDef::Primitive(p) = &nt.definition else {
3802 panic!("{id} not primitive");
3803 };
3804 (p.kind, p.constraints.format_extension.clone())
3805 };
3806 assert_eq!(prim("Plain"), (PrimitiveKind::String, None));
3807 assert_eq!(
3808 prim("Stamp"),
3809 (PrimitiveKind::String, Some("date-time".into()))
3810 );
3811 assert_eq!(prim("Mail"), (PrimitiveKind::String, Some("email".into())));
3812 assert_eq!(prim("Avatar"), (PrimitiveKind::String, Some("byte".into())));
3813 assert_eq!(
3814 prim("Tally"),
3815 (PrimitiveKind::Integer, Some("int32".into()))
3816 );
3817 assert_eq!(prim("Big"), (PrimitiveKind::Integer, Some("int64".into())));
3818 assert_eq!(
3819 prim("Money"),
3820 (PrimitiveKind::String, Some("decimal".into()))
3821 );
3822 assert_eq!(prim("Flag"), (PrimitiveKind::Bool, None));
3823 }
3824}