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