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