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);
182
183 scan_unused_component_path_items(&mut ctx, root_map, &mut ptr);
188 scan_unused_component_media_types(&mut ctx, root_map, &mut ptr);
189
190 let json_schema_dialect = root_map
193 .get("jsonSchemaDialect")
194 .and_then(J::as_str)
195 .map(String::from);
196 let self_url = root_map.get("$self").and_then(J::as_str).map(String::from);
197
198 let mut ir = Ir {
200 info: ctx.info.take().unwrap_or(ApiInfo {
201 title: String::new(),
202 version: String::new(),
203 description: None,
204 summary: None,
205 terms_of_service: None,
206 contact: None,
207 license_name: None,
208 license_url: None,
209 license_identifier: None,
210 extensions: vec![],
211 }),
212 operations: std::mem::take(&mut ctx.operations),
213 types: ctx.types.values().cloned().collect::<Vec<_>>(),
214 security_schemes: std::mem::take(&mut ctx.security_schemes),
215 servers: std::mem::take(&mut ctx.servers),
216 webhooks: std::mem::take(&mut ctx.webhooks),
217 external_docs: root_external_docs,
218 tags,
219 json_schema_dialect,
220 self_url,
221 values: std::mem::take(&mut ctx.values).finish(),
222 };
223 let mut diagnostics = std::mem::take(&mut ctx.diagnostics);
224 diagnostics.extend(finalize::canonicalize(&mut ir));
225
226 Ok(ParseOutput {
227 spec: Some(ir),
228 diagnostics,
229 })
230}
231
232fn parse_tags(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) -> Vec<Tag> {
238 let Some(J::Array(tags)) = root.get("tags") else {
239 return Vec::new();
240 };
241 let mut out: Vec<Tag> = Vec::new();
242 let mut declared_names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
243 ptr.with_token("tags", |ptr| {
244 for tag in tags.iter() {
247 if let Some(name) = tag
248 .as_object()
249 .and_then(|m| m.get("name"))
250 .and_then(J::as_str)
251 {
252 declared_names.insert(name.to_string());
253 }
254 }
255 for (i, tag) in tags.iter().enumerate() {
256 ptr.with_index(i, |ptr| {
257 let Some(map) = tag.as_object() else {
258 ctx.push_diag(diag::err(
259 diag::E_INVALID_TYPE,
260 "tag must be an object",
261 ptr.loc(ctx.file),
262 ));
263 return;
264 };
265 let Some(name) = map.get("name").and_then(J::as_str) else {
266 ctx.push_diag(diag::err(
267 diag::E_MISSING_FIELD,
268 "tag is missing required `name`",
269 ptr.loc(ctx.file),
270 ));
271 return;
272 };
273 let summary = map.get("summary").and_then(J::as_str).map(String::from);
274 let description = map.get("description").and_then(J::as_str).map(String::from);
275 let external_docs = parse_external_docs(ctx, map.get("externalDocs"), ptr);
276 let kind = map.get("kind").and_then(J::as_str).map(String::from);
277 let parent_raw = map.get("parent").and_then(J::as_str).map(String::from);
278 let parent = match parent_raw {
279 Some(p) if !declared_names.contains(&p) => {
280 ctx.push_diag(diag::warn(
281 diag::W_TAG_PARENT_DANGLING,
282 format!(
283 "tag `{name}` references parent `{p}`, which is not declared in \
284 the top-level `tags` array; dropping the parent reference."
285 ),
286 ptr.loc(ctx.file),
287 ));
288 None
289 }
290 other => other,
291 };
292 let extensions = operations::collect_extensions(ctx, map, ptr);
293 out.push(Tag {
294 name: name.to_string(),
295 summary,
296 description,
297 external_docs,
298 parent,
299 kind,
300 extensions,
301 });
302 });
303 }
304 });
305 out.sort_by(|a, b| a.name.cmp(&b.name));
307 out
308}
309
310const ACCEPTED_VERSION_PREFIXES: &[&str] = &["3.0.", "3.1.", "3.2."];
314
315pub(crate) fn parse_examples(
325 ctx: &mut Ctx,
326 map: &serde_json::Map<String, J>,
327 ptr: &mut Ptr,
328) -> Vec<(String, Example)> {
329 let mut out = Vec::new();
330 if let Some(raw) = map.get("example") {
332 ptr.with_token("example", |_ptr| {
333 let value = Some(ctx.values.intern_json(raw));
334 out.push((
335 "_default".to_string(),
336 Example {
337 summary: None,
338 description: None,
339 value,
340 external_value: None,
341 data_value: None,
342 serialized_value: None,
343 },
344 ));
345 });
346 }
347 if let Some(J::Object(named)) = map.get("examples") {
349 ptr.with_token("examples", |ptr| {
350 for (name, entry) in named {
351 ptr.with_token(name, |ptr| {
352 crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
353 let Some(emap) = resolved.as_object() else {
354 ctx.push_diag(diag::err(
355 diag::E_INVALID_TYPE,
356 "example must be an object",
357 ptr.loc(ctx.file),
358 ));
359 return Some(());
360 };
361 let summary = emap.get("summary").and_then(J::as_str).map(String::from);
362 let description = emap
363 .get("description")
364 .and_then(J::as_str)
365 .map(String::from);
366 let external_value = emap
367 .get("externalValue")
368 .and_then(J::as_str)
369 .map(String::from);
370 let value = emap.get("value").map(|raw| ctx.values.intern_json(raw));
371 let data_value =
376 emap.get("dataValue").map(|raw| ctx.values.intern_json(raw));
377 let serialized_value = emap
378 .get("serializedValue")
379 .and_then(J::as_str)
380 .map(String::from);
381 if value.is_some() && external_value.is_some() {
382 ctx.push_diag(diag::err(
383 diag::E_EXAMPLE_VALUE_CONFLICT,
384 format!(
385 "example `{name}` declares both `value` and `externalValue`; \
386 OAS §4.7.20 makes them mutually exclusive. Keeping `value`."
387 ),
388 ptr.loc(ctx.file),
389 ));
390 }
391 let kept_external = if value.is_some() {
392 None
393 } else {
394 external_value
395 };
396 out.push((
397 name.clone(),
398 Example {
399 summary,
400 description,
401 value,
402 external_value: kept_external,
403 data_value,
404 serialized_value,
405 },
406 ));
407 Some(())
408 });
409 });
410 }
411 });
412 }
413 out
414}
415
416fn scan_unused_component_path_items(
423 ctx: &mut Ctx,
424 root: &serde_json::Map<String, J>,
425 ptr: &mut Ptr,
426) {
427 let Some(J::Object(components)) = root.get("components") else {
428 return;
429 };
430 let Some(J::Object(path_items)) = components.get("pathItems") else {
431 return;
432 };
433 ptr.with_token("components", |ptr| {
434 ptr.with_token("pathItems", |ptr| {
435 for name in path_items.keys() {
436 if !ctx.referenced_component_path_items.contains(name) {
437 ptr.with_token(name, |ptr| {
438 ctx.push_diag(diag::warn(
439 diag::W_COMPONENT_PATH_ITEM_UNUSED,
440 format!(
441 "components.pathItems.`{name}` is declared but never \
442 referenced from paths, webhooks, or callbacks. The \
443 declaration is silently invisible to generators."
444 ),
445 ptr.loc(ctx.file),
446 ));
447 });
448 }
449 }
450 });
451 });
452}
453
454fn scan_unused_component_media_types(
461 ctx: &mut Ctx,
462 root: &serde_json::Map<String, J>,
463 ptr: &mut Ptr,
464) {
465 let Some(J::Object(components)) = root.get("components") else {
466 return;
467 };
468 let Some(J::Object(media_types)) = components.get("mediaTypes") else {
469 return;
470 };
471 ptr.with_token("components", |ptr| {
472 ptr.with_token("mediaTypes", |ptr| {
473 for name in media_types.keys() {
474 if !ctx.referenced_component_media_types.contains(name) {
475 ptr.with_token(name, |ptr| {
476 ctx.push_diag(diag::warn(
477 diag::W_COMPONENT_MEDIA_TYPE_UNUSED,
478 format!(
479 "components.mediaTypes.`{name}` is declared but never \
480 referenced. The declaration is silently invisible to \
481 generators."
482 ),
483 ptr.loc(ctx.file),
484 ));
485 });
486 }
487 }
488 });
489 });
490}
491
492pub(crate) fn parse_callbacks(
500 ctx: &mut Ctx,
501 value: Option<&J>,
502 ptr: &mut Ptr,
503 seen_op_ids: &mut std::collections::HashSet<String>,
504) -> Vec<Callback> {
505 let Some(J::Object(named)) = value else {
506 return Vec::new();
507 };
508 let mut out = Vec::new();
509 ptr.with_token("callbacks", |ptr| {
510 for (name, entry) in named {
511 ptr.with_token(name, |ptr| {
512 crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
513 let Some(emap) = resolved.as_object() else {
514 ctx.push_diag(diag::err(
515 diag::E_INVALID_TYPE,
516 "callback must be an object",
517 ptr.loc(ctx.file),
518 ));
519 return Some(());
520 };
521 let extensions = operations::collect_extensions(ctx, emap, ptr);
523 for (expr, path_item) in emap {
524 if expr.starts_with("x-") {
527 continue;
528 }
529 ptr.with_token(expr, |ptr| {
530 let ops =
531 operations::parse_path_item(ctx, expr, path_item, ptr, seen_op_ids);
532 let operation_ids: Vec<String> =
537 ops.iter().map(|o| o.id.clone()).collect();
538 ctx.operations.extend(ops);
539 out.push(Callback {
540 name: name.clone(),
541 expression: expr.clone(),
542 operation_ids,
543 extensions: extensions.clone(),
544 });
545 });
546 }
547 Some(())
548 });
549 });
550 }
551 });
552 out
553}
554
555pub(crate) fn parse_links(ctx: &mut Ctx, value: Option<&J>, ptr: &mut Ptr) -> Vec<(String, Link)> {
561 let Some(J::Object(named)) = value else {
562 return Vec::new();
563 };
564 let mut out = Vec::new();
565 ptr.with_token("links", |ptr| {
566 for (name, entry) in named {
567 ptr.with_token(name, |ptr| {
568 crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
569 let Some(lmap) = resolved.as_object() else {
570 ctx.push_diag(diag::err(
571 diag::E_INVALID_TYPE,
572 "link must be an object",
573 ptr.loc(ctx.file),
574 ));
575 return Some(());
576 };
577 let operation_ref = lmap
578 .get("operationRef")
579 .and_then(J::as_str)
580 .map(String::from);
581 let raw_operation_id = lmap
582 .get("operationId")
583 .and_then(J::as_str)
584 .map(String::from);
585 let operation_id = if operation_ref.is_some() && raw_operation_id.is_some() {
586 ctx.push_diag(diag::err(
587 diag::E_LINK_OP_CONFLICT,
588 format!(
589 "link `{name}` declares both `operationRef` and `operationId`; \
590 OAS §4.7.21 makes them mutually exclusive. Keeping `operationRef`."
591 ),
592 ptr.loc(ctx.file),
593 ));
594 None
595 } else {
596 raw_operation_id
597 };
598 let parameters = lmap
599 .get("parameters")
600 .and_then(|v| v.as_object())
601 .map(|m| {
602 m.iter()
603 .map(|(k, raw)| (k.clone(), ctx.values.intern_json(raw)))
604 .collect()
605 })
606 .unwrap_or_default();
607 let request_body = lmap
608 .get("requestBody")
609 .map(|raw| ctx.values.intern_json(raw));
610 let description = lmap
611 .get("description")
612 .and_then(J::as_str)
613 .map(String::from);
614 let server = lmap.get("server").and_then(|s| {
615 s.as_object().and_then(|m| {
616 let url = m.get("url").and_then(J::as_str)?;
617 let description =
618 m.get("description").and_then(J::as_str).map(String::from);
619 let server_name = m.get("name").and_then(J::as_str).map(String::from);
620 Some(Server {
621 url: url.to_string(),
622 description,
623 name: server_name,
624 variables: Vec::new(),
625 extensions: Vec::new(),
626 })
627 })
628 });
629 let extensions = operations::collect_extensions(ctx, lmap, ptr);
630 out.push((
631 name.clone(),
632 Link {
633 operation_ref,
634 operation_id,
635 parameters,
636 request_body,
637 description,
638 server,
639 extensions,
640 },
641 ));
642 Some(())
643 });
644 });
645 }
646 });
647 out
648}
649
650pub(crate) fn parse_xml(
655 ctx: &mut Ctx,
656 map: &serde_json::Map<String, J>,
657 ptr: &mut Ptr,
658) -> Option<XmlObject> {
659 let xml = map.get("xml")?;
660 let xml_map = xml.as_object()?;
661 let mut out = None;
662 ptr.with_token("xml", |ptr| {
663 let name = xml_map.get("name").and_then(J::as_str).map(String::from);
664 let namespace = xml_map
665 .get("namespace")
666 .and_then(J::as_str)
667 .map(String::from);
668 let prefix = xml_map.get("prefix").and_then(J::as_str).map(String::from);
669 let attribute = xml_map
670 .get("attribute")
671 .and_then(J::as_bool)
672 .unwrap_or(false);
673 let wrapped = xml_map.get("wrapped").and_then(J::as_bool).unwrap_or(false);
674 let text = xml_map.get("text").and_then(J::as_bool).unwrap_or(false);
675 let ordered = xml_map.get("ordered").and_then(J::as_bool).unwrap_or(false);
676 let extensions = operations::collect_extensions(ctx, xml_map, ptr);
677 out = Some(XmlObject {
678 name,
679 namespace,
680 prefix,
681 attribute,
682 wrapped,
683 text,
684 ordered,
685 extensions,
686 });
687 });
688 out
689}
690
691pub(crate) fn parse_default(
695 ctx: &mut Ctx,
696 map: &serde_json::Map<String, J>,
697 _ptr: &mut Ptr,
698 _site: &str,
699) -> Option<forge_ir::ValueRef> {
700 let raw = map.get("default")?;
701 Some(ctx.values.intern_json(raw))
702}
703
704pub(crate) fn parse_external_docs(
709 ctx: &mut Ctx,
710 value: Option<&J>,
711 ptr: &mut Ptr,
712) -> Option<ExternalDocs> {
713 let map = value?.as_object()?;
714 let mut out = None;
715 ptr.with_token("externalDocs", |ptr| {
716 let Some(url) = map.get("url").and_then(J::as_str) else {
717 ctx.push_diag(diag::warn(
718 diag::W_EXTERNAL_DOCS_NO_URL,
719 "externalDocs is missing required `url`; dropping the block.",
720 ptr.loc(ctx.file),
721 ));
722 return;
723 };
724 let description = map.get("description").and_then(J::as_str).map(String::from);
725 out = Some(ExternalDocs {
726 description,
727 url: url.to_string(),
728 });
729 });
730 out
731}
732
733fn check_version(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) -> bool {
734 if root.contains_key("swagger") {
737 ptr.with_token("swagger", |ptr| {
738 ctx.push_diag(diag::err(
739 diag::E_UNSUPPORTED_VERSION,
740 "OpenAPI 2.0 (Swagger) is not supported and is not on the roadmap. \
741 Convert to OpenAPI 3.0 upstream (e.g. `swagger2openapi`) before invoking forge.",
742 ptr.loc(ctx.file),
743 ));
744 });
745 return false;
746 }
747 let version = root.get("openapi").and_then(J::as_str);
748 match version {
749 Some(v) if ACCEPTED_VERSION_PREFIXES.iter().any(|p| v.starts_with(p)) => {
750 ctx.is_oas_3_0 = v.starts_with("3.0.");
754 true
755 }
756 Some(other) => {
757 let msg = if other.starts_with("2.") || other.starts_with("1.") {
758 format!(
759 "OpenAPI {other} is not supported and is not on the roadmap. \
760 Convert to OpenAPI 3.x upstream before invoking forge."
761 )
762 } else {
763 format!("unsupported OpenAPI version `{other}`; expected 3.0.x / 3.1.x / 3.2.x")
764 };
765 ptr.with_token("openapi", |ptr| {
766 ctx.push_diag(diag::err(
767 diag::E_UNSUPPORTED_VERSION,
768 msg,
769 ptr.loc(ctx.file),
770 ));
771 });
772 false
773 }
774 None => {
775 ctx.push_diag(diag::err(
776 diag::E_MISSING_FIELD,
777 "missing required `openapi` field",
778 SpecLocation::new(""),
779 ));
780 false
781 }
782 }
783}
784
785fn parse_info(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
786 let Some(J::Object(info)) = root.get("info") else {
787 ctx.push_diag(diag::err(
788 diag::E_MISSING_FIELD,
789 "missing required `info` object",
790 ptr.loc(ctx.file),
791 ));
792 return;
793 };
794 ptr.with_token("info", |ptr| {
795 let title = info.get("title").and_then(J::as_str).unwrap_or_else(|| {
796 ctx.push_diag(diag::err(
797 diag::E_MISSING_FIELD,
798 "info is missing `title`",
799 ptr.loc(ctx.file),
800 ));
801 ""
802 });
803 let version = info.get("version").and_then(J::as_str).unwrap_or_else(|| {
804 ctx.push_diag(diag::err(
805 diag::E_MISSING_FIELD,
806 "info is missing `version`",
807 ptr.loc(ctx.file),
808 ));
809 ""
810 });
811 let description = info
812 .get("description")
813 .and_then(J::as_str)
814 .map(String::from);
815 let summary = info.get("summary").and_then(J::as_str).map(String::from);
816 let terms_of_service = info
817 .get("termsOfService")
818 .and_then(J::as_str)
819 .map(String::from);
820 let contact =
821 info.get("contact")
822 .and_then(|v| v.as_object())
823 .and_then(|m| -> Option<Contact> {
824 let name = m.get("name").and_then(J::as_str).map(String::from);
825 let url = m.get("url").and_then(J::as_str).map(String::from);
826 let email = m.get("email").and_then(J::as_str).map(String::from);
827 if name.is_none() && url.is_none() && email.is_none() {
828 None
829 } else {
830 Some(Contact { name, url, email })
831 }
832 });
833 let license = info.get("license").and_then(|l| l.as_object());
834 let license_name = license
835 .and_then(|m| m.get("name"))
836 .and_then(J::as_str)
837 .map(String::from);
838 let license_url = license
839 .and_then(|m| m.get("url"))
840 .and_then(J::as_str)
841 .map(String::from);
842 let license_identifier = license
843 .and_then(|m| m.get("identifier"))
844 .and_then(J::as_str)
845 .map(String::from);
846 let extensions = operations::collect_extensions(ctx, info, ptr);
847 ctx.info = Some(ApiInfo {
848 title: title.to_string(),
849 version: version.to_string(),
850 description,
851 summary,
852 terms_of_service,
853 contact,
854 license_name,
855 license_url,
856 license_identifier,
857 extensions,
858 });
859 });
860}
861
862fn parse_servers(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
863 let servers = parse_servers_array(ctx, root.get("servers"), ptr);
864 ctx.servers.extend(servers);
865}
866
867pub(crate) fn parse_servers_array(ctx: &mut Ctx, value: Option<&J>, ptr: &mut Ptr) -> Vec<Server> {
872 let Some(J::Array(items)) = value else {
873 return Vec::new();
874 };
875 let mut out = Vec::new();
876 ptr.with_token("servers", |ptr| {
877 for (i, item) in items.iter().enumerate() {
878 ptr.with_index(i, |ptr| {
879 let Some(map) = item.as_object() else {
880 ctx.push_diag(diag::err(
881 diag::E_INVALID_TYPE,
882 "server must be an object",
883 ptr.loc(ctx.file),
884 ));
885 return;
886 };
887 let Some(url) = map.get("url").and_then(J::as_str) else {
888 ctx.push_diag(diag::err(
889 diag::E_MISSING_FIELD,
890 "server is missing `url`",
891 ptr.loc(ctx.file),
892 ));
893 return;
894 };
895 let description = map.get("description").and_then(J::as_str).map(String::from);
896 let server_name = map.get("name").and_then(J::as_str).map(String::from);
897 let mut variables: Vec<(String, ServerVariable)> = Vec::new();
898 if let Some(J::Object(vars)) = map.get("variables") {
899 ptr.with_token("variables", |ptr| {
900 for (name, v) in vars {
901 ptr.with_token(name, |ptr| {
902 let Some(vmap) = v.as_object() else { return };
903 let Some(default) = vmap.get("default").and_then(J::as_str) else {
904 return;
905 };
906 let var_extensions = operations::collect_extensions(ctx, vmap, ptr);
907 variables.push((
908 name.clone(),
909 ServerVariable {
910 default: default.to_string(),
911 r#enum: vmap.get("enum").and_then(|e| {
912 e.as_array().map(|arr| {
913 arr.iter()
914 .filter_map(|v| v.as_str().map(String::from))
915 .collect()
916 })
917 }),
918 description: vmap
919 .get("description")
920 .and_then(J::as_str)
921 .map(String::from),
922 extensions: var_extensions,
923 },
924 ));
925 });
926 }
927 });
928 }
929 let extensions = operations::collect_extensions(ctx, map, ptr);
930 out.push(Server {
931 url: url.to_string(),
932 description,
933 name: server_name,
934 variables,
935 extensions,
936 });
937 });
938 }
939 });
940 out
941}
942
943fn register_component_schemas(ctx: &mut Ctx, root: &serde_json::Map<String, J>) {
944 let Some(J::Object(components)) = root.get("components") else {
945 return;
946 };
947 let Some(J::Object(schemas)) = components.get("schemas") else {
948 return;
949 };
950 for name in schemas.keys() {
951 let id = sanitize::ident(name);
952 ctx.refs_mut().register(&id);
953 }
954}
955
956fn walk_component_schemas(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
957 let Some(J::Object(components)) = root.get("components") else {
958 return;
959 };
960 let Some(J::Object(schemas)) = components.get("schemas") else {
961 return;
962 };
963 pre_register_external_named_hints(ctx, schemas);
970 let order = order_components_by_allof(schemas);
971 ptr.with_token("components", |ptr| {
972 ptr.with_token("schemas", |ptr| {
973 for name in &order {
974 let Some(schema) = schemas.get(name) else {
975 continue;
976 };
977 ptr.with_token(name, |ptr| {
978 let key = (
983 ctx.current_doc.clone(),
984 format!("/components/schemas/{name}"),
985 );
986 ctx.walking.insert(key.clone());
987 let _ = parse_schema(ctx, schema, ptr, NameHint::Named(name.clone()));
988 ctx.walking.remove(&key);
989 });
990 }
991 });
992 });
993}
994
995fn pre_register_external_named_hints(ctx: &mut Ctx, schemas: &serde_json::Map<String, J>) {
1005 let current_doc = ctx.current_doc.clone();
1006 for (name, schema) in schemas {
1007 let Some(map) = schema.as_object() else {
1008 continue;
1009 };
1010 let Some(J::String(raw)) = map.get("$ref") else {
1011 continue;
1012 };
1013 let (file_part, fragment) = crate::external::split_ref(raw);
1014 if file_part.is_empty() || crate::external::is_url(file_part) {
1015 continue;
1016 }
1017 let Ok(loaded) = ctx.resolver.load(raw, ¤t_doc) else {
1018 continue;
1019 };
1020 let canonical = loaded.canonical_path.clone();
1021 crate::schema::ensure_doc_registered(ctx, &canonical, &loaded.root);
1022 ctx.external_ref_to_id
1023 .entry((canonical, fragment.to_string()))
1024 .or_insert_with(|| crate::sanitize::ident(name));
1025 }
1026}
1027
1028fn order_components_by_allof(schemas: &serde_json::Map<String, J>) -> Vec<String> {
1033 use std::collections::{BTreeMap, BTreeSet};
1034
1035 let mut deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1036 for (name, schema) in schemas {
1037 let mut targets: BTreeSet<String> = BTreeSet::new();
1038 collect_allof_ref_targets(schema, &mut targets);
1039 targets.retain(|t| schemas.contains_key(t) && t != name);
1040 deps.insert(name.clone(), targets);
1041 }
1042
1043 let mut visited: BTreeSet<String> = BTreeSet::new();
1044 let mut ordered: Vec<String> = Vec::new();
1045 let mut all_names: Vec<String> = schemas.keys().cloned().collect();
1046 all_names.sort();
1047
1048 loop {
1049 let next = all_names.iter().find(|n| {
1050 !visited.contains(*n)
1051 && deps
1052 .get(*n)
1053 .map(|d| d.iter().all(|t| visited.contains(t)))
1054 .unwrap_or(true)
1055 });
1056 match next {
1057 Some(name) => {
1058 let n = name.clone();
1059 visited.insert(n.clone());
1060 ordered.push(n);
1061 }
1062 None => break,
1063 }
1064 }
1065 for n in all_names {
1067 if !visited.contains(&n) {
1068 ordered.push(n);
1069 }
1070 }
1071 ordered
1072}
1073
1074fn collect_allof_ref_targets(value: &J, out: &mut std::collections::BTreeSet<String>) {
1075 let Some(map) = value.as_object() else {
1076 return;
1077 };
1078 if let Some(J::Array(parts)) = map.get("allOf") {
1079 for part in parts {
1080 if let Some(rs) = part
1081 .as_object()
1082 .and_then(|m| m.get("$ref"))
1083 .and_then(|r| r.as_str())
1084 {
1085 if let Some(name) = rs.strip_prefix("#/components/schemas/") {
1086 out.insert(name.to_string());
1087 }
1088 }
1089 }
1090 }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096
1097 #[test]
1098 fn empty_input_errors() {
1099 let err = parse_str("").unwrap_err();
1100 matches!(err, ParseError::Empty);
1101 }
1102
1103 #[test]
1104 fn invalid_json_errors() {
1105 let err = parse_str("{not json").unwrap_err();
1106 matches!(err, ParseError::InvalidJson(_));
1107 }
1108
1109 #[test]
1110 fn root_array_errors() {
1111 let err = parse_str("[]").unwrap_err();
1112 matches!(err, ParseError::NotObject);
1113 }
1114
1115 #[test]
1116 fn unsupported_version_diagnostic() {
1117 let src = r#"{"openapi":"4.0.0","info":{"title":"x","version":"1"},"paths":{}}"#;
1119 let out = parse_str(src).unwrap();
1120 assert!(out.spec.is_none());
1121 assert_eq!(out.diagnostics.len(), 1);
1122 assert_eq!(out.diagnostics[0].code, diag::E_UNSUPPORTED_VERSION);
1123 }
1124
1125 #[test]
1126 fn minimal_spec_round_trips() {
1127 let src = r#"{
1128 "openapi":"3.0.3",
1129 "info":{"title":"t","version":"1"},
1130 "paths":{}
1131 }"#;
1132 let out = parse_str(src).unwrap();
1133 let ir = out.spec.unwrap();
1134 assert_eq!(ir.info.title, "t");
1135 assert!(ir.operations.is_empty());
1136 assert!(ir.types.is_empty());
1137 }
1138
1139 #[test]
1140 fn info_full_block_populates_every_field() {
1141 let src = r#"{
1142 "openapi":"3.1.0",
1143 "info":{
1144 "title":"t",
1145 "version":"1",
1146 "summary":"s",
1147 "description":"d",
1148 "termsOfService":"https://tos.example",
1149 "contact":{
1150 "name":"API Team",
1151 "url":"https://example.com",
1152 "email":"team@example.com"
1153 },
1154 "license":{
1155 "name":"Apache 2.0",
1156 "url":"https://www.apache.org/licenses/LICENSE-2.0",
1157 "identifier":"Apache-2.0"
1158 }
1159 },
1160 "paths":{}
1161 }"#;
1162 let ir = parse_str(src).unwrap().spec.unwrap();
1163 assert_eq!(ir.info.summary.as_deref(), Some("s"));
1164 assert_eq!(ir.info.description.as_deref(), Some("d"));
1165 assert_eq!(
1166 ir.info.terms_of_service.as_deref(),
1167 Some("https://tos.example")
1168 );
1169 let contact = ir.info.contact.expect("contact populated");
1170 assert_eq!(contact.name.as_deref(), Some("API Team"));
1171 assert_eq!(contact.url.as_deref(), Some("https://example.com"));
1172 assert_eq!(contact.email.as_deref(), Some("team@example.com"));
1173 assert_eq!(ir.info.license_name.as_deref(), Some("Apache 2.0"));
1174 assert_eq!(
1175 ir.info.license_url.as_deref(),
1176 Some("https://www.apache.org/licenses/LICENSE-2.0")
1177 );
1178 assert_eq!(ir.info.license_identifier.as_deref(), Some("Apache-2.0"));
1179 }
1180
1181 #[test]
1182 fn info_contact_object_with_no_known_keys_is_none() {
1183 let src = r#"{
1187 "openapi":"3.0.0",
1188 "info":{
1189 "title":"t",
1190 "version":"1",
1191 "contact":{ "x-vendor": "acme" }
1192 },
1193 "paths":{}
1194 }"#;
1195 let ir = parse_str(src).unwrap().spec.unwrap();
1196 assert!(ir.info.contact.is_none());
1197 }
1198
1199 #[test]
1200 fn external_docs_populated_at_root_operation_and_schema() {
1201 let src = r#"{
1202 "openapi":"3.0.3",
1203 "info":{"title":"t","version":"1"},
1204 "externalDocs":{"description":"top","url":"https://example.com"},
1205 "paths":{
1206 "/x":{
1207 "get":{
1208 "operationId":"getX",
1209 "externalDocs":{"url":"https://example.com/op"},
1210 "responses":{"200":{"description":"ok"}}
1211 }
1212 }
1213 },
1214 "components":{
1215 "schemas":{
1216 "Foo":{
1217 "type":"object",
1218 "externalDocs":{"description":"d","url":"https://example.com/foo"}
1219 }
1220 }
1221 }
1222 }"#;
1223 let ir = parse_str(src).unwrap().spec.unwrap();
1224 let root = ir.external_docs.expect("root externalDocs");
1225 assert_eq!(root.url, "https://example.com");
1226 assert_eq!(root.description.as_deref(), Some("top"));
1227
1228 let op_docs = ir.operations[0]
1229 .external_docs
1230 .as_ref()
1231 .expect("op externalDocs");
1232 assert_eq!(op_docs.url, "https://example.com/op");
1233 assert!(op_docs.description.is_none());
1234
1235 let foo = ir.types.iter().find(|t| t.id == "Foo").expect("Foo type");
1236 let schema_docs = foo.external_docs.as_ref().expect("schema externalDocs");
1237 assert_eq!(schema_docs.url, "https://example.com/foo");
1238 assert_eq!(schema_docs.description.as_deref(), Some("d"));
1239 }
1240
1241 #[test]
1242 fn external_docs_missing_url_warns_and_drops() {
1243 let src = r#"{
1244 "openapi":"3.0.3",
1245 "info":{"title":"t","version":"1"},
1246 "externalDocs":{"description":"oops"},
1247 "paths":{}
1248 }"#;
1249 let out = parse_str(src).unwrap();
1250 let ir = out.spec.unwrap();
1251 assert!(ir.external_docs.is_none());
1252 assert!(out
1253 .diagnostics
1254 .iter()
1255 .any(|d| d.code == diag::W_EXTERNAL_DOCS_NO_URL));
1256 }
1257
1258 #[test]
1259 fn webhooks_carry_routing_name_and_multiple_methods() {
1260 let src = r#"{
1261 "openapi":"3.1.0",
1262 "info":{"title":"t","version":"1"},
1263 "paths":{},
1264 "webhooks":{
1265 "newPet":{
1266 "post":{
1267 "operationId":"newPetCreated",
1268 "responses":{"200":{"description":"ok"}}
1269 },
1270 "delete":{
1271 "operationId":"newPetDeleted",
1272 "responses":{"200":{"description":"ok"}}
1273 }
1274 }
1275 }
1276 }"#;
1277 let ir = parse_str(src).unwrap().spec.unwrap();
1278 assert_eq!(ir.webhooks.len(), 1);
1279 let w = &ir.webhooks[0];
1280 assert_eq!(w.name, "newPet");
1281 assert_eq!(w.operations.len(), 2);
1284 assert!(w.operations.iter().any(|o| o.id == "newPetCreated"));
1285 assert!(w.operations.iter().any(|o| o.id == "newPetDeleted"));
1286 }
1287
1288 #[test]
1289 fn webhooks_sort_by_name() {
1290 let src = r#"{
1291 "openapi":"3.1.0",
1292 "info":{"title":"t","version":"1"},
1293 "paths":{},
1294 "webhooks":{
1295 "zebra":{"post":{"operationId":"z","responses":{"200":{"description":"ok"}}}},
1296 "alpha":{"post":{"operationId":"a","responses":{"200":{"description":"ok"}}}}
1297 }
1298 }"#;
1299 let ir = parse_str(src).unwrap().spec.unwrap();
1300 assert_eq!(ir.webhooks[0].name, "alpha");
1301 assert_eq!(ir.webhooks[1].name, "zebra");
1302 }
1303
1304 #[test]
1305 fn response_headers_use_dedicated_header_struct() {
1306 let src = r#"{
1307 "openapi":"3.0.3",
1308 "info":{"title":"t","version":"1"},
1309 "paths":{
1310 "/x":{
1311 "get":{
1312 "operationId":"x",
1313 "responses":{
1314 "200":{
1315 "description":"ok",
1316 "headers":{
1317 "X-Trace":{
1318 "description":"trace id",
1319 "required":true,
1320 "schema":{"type":"string"}
1321 }
1322 }
1323 }
1324 }
1325 }
1326 }
1327 }
1328 }"#;
1329 let ir = parse_str(src).unwrap().spec.unwrap();
1330 let resp = &ir.operations[0].responses[0];
1331 assert_eq!(resp.headers.len(), 1);
1332 let (name, header) = &resp.headers[0];
1333 assert_eq!(name, "X-Trace");
1334 assert!(header.required);
1335 assert_eq!(header.documentation.as_deref(), Some("trace id"));
1336 }
1339
1340 #[test]
1341 fn openid_connect_security_scheme_round_trips() {
1342 let src = r#"{
1343 "openapi":"3.0.3",
1344 "info":{"title":"t","version":"1"},
1345 "paths":{},
1346 "components":{
1347 "securitySchemes":{
1348 "oidc":{
1349 "type":"openIdConnect",
1350 "openIdConnectUrl":"https://example.com/.well-known/openid-configuration"
1351 }
1352 }
1353 }
1354 }"#;
1355 let ir = parse_str(src).unwrap().spec.unwrap();
1356 let scheme = ir
1357 .security_schemes
1358 .iter()
1359 .find(|s| s.id == "oidc")
1360 .expect("oidc scheme present");
1361 match &scheme.kind {
1362 forge_ir::SecuritySchemeKind::OpenIdConnect { url } => {
1363 assert_eq!(url, "https://example.com/.well-known/openid-configuration");
1364 }
1365 other => panic!("expected OpenIdConnect, got {other:?}"),
1366 }
1367 }
1368
1369 #[test]
1370 fn openid_connect_missing_url_errors() {
1371 let src = r#"{
1372 "openapi":"3.0.3",
1373 "info":{"title":"t","version":"1"},
1374 "paths":{},
1375 "components":{
1376 "securitySchemes":{
1377 "oidc":{"type":"openIdConnect"}
1378 }
1379 }
1380 }"#;
1381 let out = parse_str(src).unwrap();
1382 assert!(out.spec.unwrap().security_schemes.is_empty());
1384 assert!(out
1386 .diagnostics
1387 .iter()
1388 .any(|d| d.code == diag::E_MISSING_FIELD));
1389 }
1390
1391 #[test]
1392 fn ref_siblings_warn_on_oas_3_0() {
1393 let src = r##"{
1394 "openapi":"3.0.3",
1395 "info":{"title":"t","version":"1"},
1396 "paths":{},
1397 "components":{
1398 "schemas":{
1399 "A":{"type":"string"},
1400 "B":{"$ref":"#/components/schemas/A","description":"sibling"}
1401 }
1402 }
1403 }"##;
1404 let out = parse_str(src).unwrap();
1405 let diags = out.diagnostics;
1406 let warning = diags
1407 .iter()
1408 .find(|d| d.code == diag::W_REF_SIBLINGS_3_0)
1409 .expect("warning emitted");
1410 assert!(warning.message.contains("description"));
1411 }
1412
1413 #[test]
1414 fn ref_siblings_dont_warn_on_oas_3_1() {
1415 let src = r##"{
1416 "openapi":"3.1.0",
1417 "info":{"title":"t","version":"1"},
1418 "paths":{},
1419 "components":{
1420 "schemas":{
1421 "A":{"type":"string"},
1422 "B":{"$ref":"#/components/schemas/A","description":"sibling"}
1423 }
1424 }
1425 }"##;
1426 let out = parse_str(src).unwrap();
1427 assert!(!out
1428 .diagnostics
1429 .iter()
1430 .any(|d| d.code == diag::W_REF_SIBLINGS_3_0));
1431 }
1432
1433 #[test]
1434 fn ref_with_only_x_extensions_does_not_warn() {
1435 let src = r##"{
1437 "openapi":"3.0.3",
1438 "info":{"title":"t","version":"1"},
1439 "paths":{},
1440 "components":{
1441 "schemas":{
1442 "A":{"type":"string"},
1443 "B":{"$ref":"#/components/schemas/A","x-vendor":"acme"}
1444 }
1445 }
1446 }"##;
1447 let out = parse_str(src).unwrap();
1448 assert!(!out
1449 .diagnostics
1450 .iter()
1451 .any(|d| d.code == diag::W_REF_SIBLINGS_3_0));
1452 }
1453
1454 #[test]
1455 fn referenced_component_path_item_lands_in_operations() {
1456 let src = r##"{
1457 "openapi":"3.1.0",
1458 "info":{"title":"t","version":"1"},
1459 "paths":{
1460 "/items":{"$ref":"#/components/pathItems/ItemsPath"}
1461 },
1462 "components":{
1463 "pathItems":{
1464 "ItemsPath":{
1465 "get":{"operationId":"list","responses":{"200":{"description":"ok"}}}
1466 }
1467 }
1468 }
1469 }"##;
1470 let out = parse_str(src).unwrap();
1471 let ir = out.spec.unwrap();
1472 assert!(ir.operations.iter().any(|o| o.id == "list"));
1474 assert!(!out
1476 .diagnostics
1477 .iter()
1478 .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1479 }
1480
1481 #[test]
1482 fn unused_component_path_item_warns() {
1483 let src = r##"{
1484 "openapi":"3.1.0",
1485 "info":{"title":"t","version":"1"},
1486 "paths":{},
1487 "components":{
1488 "pathItems":{
1489 "Orphan":{
1490 "get":{"operationId":"orphan","responses":{"200":{"description":"ok"}}}
1491 }
1492 }
1493 }
1494 }"##;
1495 let out = parse_str(src).unwrap();
1496 let ir = out.spec.unwrap();
1497 assert!(ir.operations.is_empty());
1499 assert!(out
1500 .diagnostics
1501 .iter()
1502 .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1503 }
1504
1505 #[test]
1506 fn webhook_ref_into_component_path_item_counts_as_use() {
1507 let src = r##"{
1508 "openapi":"3.1.0",
1509 "info":{"title":"t","version":"1"},
1510 "paths":{},
1511 "webhooks":{
1512 "ev":{"$ref":"#/components/pathItems/EventPath"}
1513 },
1514 "components":{
1515 "pathItems":{
1516 "EventPath":{
1517 "post":{"operationId":"ev","responses":{"200":{"description":"ok"}}}
1518 }
1519 }
1520 }
1521 }"##;
1522 let out = parse_str(src).unwrap();
1523 assert!(!out
1525 .diagnostics
1526 .iter()
1527 .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1528 }
1529
1530 #[test]
1531 fn callbacks_walk_inline_and_via_ref() {
1532 let src = r##"{
1533 "openapi":"3.0.3",
1534 "info":{"title":"t","version":"1"},
1535 "paths":{
1536 "/sub":{
1537 "post":{
1538 "operationId":"sub",
1539 "responses":{"200":{"description":"ok"}},
1540 "callbacks":{
1541 "evt":{
1542 "{$request.body#/url}":{
1543 "post":{
1544 "operationId":"evtCb",
1545 "responses":{"200":{"description":"ok"}}
1546 }
1547 }
1548 },
1549 "shared":{"$ref":"#/components/callbacks/Shared"}
1550 }
1551 }
1552 }
1553 },
1554 "components":{
1555 "callbacks":{
1556 "Shared":{
1557 "{$request.body#/sharedUrl}":{
1558 "post":{
1559 "operationId":"sharedCb",
1560 "responses":{"200":{"description":"ok"}}
1561 }
1562 }
1563 }
1564 }
1565 }
1566 }"##;
1567 let ir = parse_str(src).unwrap().spec.unwrap();
1568 let sub = ir.operations.iter().find(|o| o.id == "sub").unwrap();
1569 assert_eq!(sub.callbacks.len(), 2);
1570 let evt = sub.callbacks.iter().find(|c| c.name == "evt").unwrap();
1571 assert_eq!(evt.expression, "{$request.body#/url}");
1572 assert_eq!(evt.operation_ids, vec!["evtCb".to_string()]);
1573 let shared = sub.callbacks.iter().find(|c| c.name == "shared").unwrap();
1574 assert_eq!(shared.expression, "{$request.body#/sharedUrl}");
1575 assert_eq!(shared.operation_ids, vec!["sharedCb".to_string()]);
1576 assert!(ir.operations.iter().any(|o| o.id == "evtCb"));
1579 assert!(ir.operations.iter().any(|o| o.id == "sharedCb"));
1580 }
1581
1582 #[test]
1583 fn callback_op_id_collides_with_top_level_emits_dup_error() {
1584 let src = r##"{
1587 "openapi":"3.0.3",
1588 "info":{"title":"t","version":"1"},
1589 "paths":{
1590 "/a":{
1591 "post":{
1592 "operationId":"foo",
1593 "responses":{"200":{"description":"ok"}},
1594 "callbacks":{
1595 "x":{
1596 "{$req}":{
1597 "post":{
1598 "operationId":"foo",
1599 "responses":{"200":{"description":"ok"}}
1600 }
1601 }
1602 }
1603 }
1604 }
1605 }
1606 }
1607 }"##;
1608 let out = parse_str(src).unwrap();
1609 assert!(out
1610 .diagnostics
1611 .iter()
1612 .any(|d| d.code == diag::E_DUPLICATE_OPERATION_ID));
1613 }
1614
1615 #[test]
1616 fn response_links_populate_inline_and_via_ref() {
1617 let src = r##"{
1618 "openapi":"3.0.3",
1619 "info":{"title":"t","version":"1"},
1620 "paths":{
1621 "/u":{
1622 "get":{
1623 "operationId":"getU",
1624 "responses":{
1625 "200":{
1626 "description":"ok",
1627 "links":{
1628 "addr":{
1629 "operationId":"getA",
1630 "parameters":{"id":"$response.body#/id"},
1631 "description":"docs"
1632 },
1633 "shared":{"$ref":"#/components/links/Shared"}
1634 }
1635 }
1636 }
1637 }
1638 },
1639 "/a":{
1640 "get":{
1641 "operationId":"getA",
1642 "responses":{"200":{"description":"ok"}}
1643 }
1644 }
1645 },
1646 "components":{
1647 "links":{
1648 "Shared":{"operationId":"getA","description":"shared"}
1649 }
1650 }
1651 }"##;
1652 let ir = parse_str(src).unwrap().spec.unwrap();
1653 let op = ir.operations.iter().find(|o| o.id == "getU").unwrap();
1654 let links = &op.responses[0].links;
1655 assert_eq!(links.len(), 2);
1656 let addr = &links.iter().find(|(k, _)| k == "addr").unwrap().1;
1657 assert_eq!(addr.operation_id.as_deref(), Some("getA"));
1658 assert_eq!(addr.parameters.len(), 1);
1659 assert_eq!(addr.parameters[0].0, "id");
1660 assert_eq!(addr.description.as_deref(), Some("docs"));
1661 let shared = &links.iter().find(|(k, _)| k == "shared").unwrap().1;
1662 assert_eq!(shared.description.as_deref(), Some("shared"));
1663 assert_eq!(shared.operation_id.as_deref(), Some("getA"));
1664 }
1665
1666 #[test]
1667 fn link_with_both_operation_ref_and_id_keeps_ref() {
1668 let src = r##"{
1669 "openapi":"3.0.3",
1670 "info":{"title":"t","version":"1"},
1671 "paths":{
1672 "/u":{
1673 "get":{
1674 "operationId":"getU",
1675 "responses":{
1676 "200":{
1677 "description":"ok",
1678 "links":{
1679 "x":{
1680 "operationRef":"#/paths/~1a/get",
1681 "operationId":"getA"
1682 }
1683 }
1684 }
1685 }
1686 }
1687 }
1688 }
1689 }"##;
1690 let out = parse_str(src).unwrap();
1691 let ir = out.spec.unwrap();
1692 let link = &ir.operations[0].responses[0].links[0].1;
1693 assert!(link.operation_ref.is_some());
1694 assert!(link.operation_id.is_none());
1695 assert!(out
1696 .diagnostics
1697 .iter()
1698 .any(|d| d.code == diag::E_LINK_OP_CONFLICT));
1699 }
1700
1701 #[test]
1702 fn link_compound_parameter_survives_via_value_pool() {
1703 let src = r##"{
1704 "openapi":"3.0.3",
1705 "info":{"title":"t","version":"1"},
1706 "paths":{
1707 "/u":{
1708 "get":{
1709 "operationId":"getU",
1710 "responses":{
1711 "200":{
1712 "description":"ok",
1713 "links":{
1714 "x":{
1715 "operationId":"foo",
1716 "parameters":{"complex":["a","b"]}
1717 }
1718 }
1719 }
1720 }
1721 }
1722 }
1723 }
1724 }"##;
1725 let out = parse_str(src).unwrap();
1726 let ir = out.spec.unwrap();
1727 let link = &ir.operations[0].responses[0].links[0].1;
1728 assert_eq!(link.parameters.len(), 1);
1731 let r = link.parameters[0].1 as usize;
1732 assert!(matches!(ir.values[r], forge_ir::Value::List { .. }));
1733 }
1734
1735 #[test]
1736 fn xml_block_populates_with_all_fields() {
1737 let src = r#"{
1738 "openapi":"3.0.3",
1739 "info":{"title":"t","version":"1"},
1740 "paths":{},
1741 "components":{
1742 "schemas":{
1743 "Pet":{
1744 "type":"object",
1745 "xml":{
1746 "name":"Pet",
1747 "namespace":"http://example.com/pet",
1748 "prefix":"pt",
1749 "attribute":false,
1750 "wrapped":true,
1751 "x-vendor":"acme"
1752 }
1753 }
1754 }
1755 }
1756 }"#;
1757 let ir = parse_str(src).unwrap().spec.unwrap();
1758 let pet = ir.types.iter().find(|t| t.id == "Pet").unwrap();
1759 let xml = pet.xml.as_ref().expect("xml populated");
1760 assert_eq!(xml.name.as_deref(), Some("Pet"));
1761 assert_eq!(xml.namespace.as_deref(), Some("http://example.com/pet"));
1762 assert_eq!(xml.prefix.as_deref(), Some("pt"));
1763 assert!(!xml.attribute);
1764 assert!(xml.wrapped);
1765 assert_eq!(xml.extensions.len(), 1);
1766 assert_eq!(xml.extensions[0].0, "x-vendor");
1767 }
1768
1769 #[test]
1770 fn xml_attribute_defaults_to_false() {
1771 let src = r#"{
1772 "openapi":"3.0.3",
1773 "info":{"title":"t","version":"1"},
1774 "paths":{},
1775 "components":{
1776 "schemas":{
1777 "Foo":{"type":"string","xml":{"name":"Foo"}}
1778 }
1779 }
1780 }"#;
1781 let ir = parse_str(src).unwrap().spec.unwrap();
1782 let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1783 let xml = foo.xml.as_ref().unwrap();
1784 assert!(!xml.attribute);
1785 assert!(!xml.wrapped);
1786 }
1787
1788 #[test]
1789 fn xml_absent_leaves_field_none() {
1790 let src = r#"{
1791 "openapi":"3.0.3",
1792 "info":{"title":"t","version":"1"},
1793 "paths":{},
1794 "components":{"schemas":{"Foo":{"type":"string"}}}
1795 }"#;
1796 let ir = parse_str(src).unwrap().spec.unwrap();
1797 let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1798 assert!(foo.xml.is_none());
1799 }
1800
1801 #[test]
1802 fn examples_populate_at_parameter_and_schema_sites() {
1803 let src = r##"{
1804 "openapi":"3.0.3",
1805 "info":{"title":"t","version":"1"},
1806 "paths":{
1807 "/x/{id}":{
1808 "get":{
1809 "operationId":"getX",
1810 "parameters":[{
1811 "name":"id","in":"path","required":true,
1812 "schema":{"type":"string"},
1813 "examples":{
1814 "short":{"summary":"S","value":"42"},
1815 "uuid":{"$ref":"#/components/examples/UuidExample"}
1816 }
1817 }],
1818 "responses":{"204":{"description":"ok"}}
1819 }
1820 }
1821 },
1822 "components":{
1823 "examples":{
1824 "UuidExample":{"summary":"UUID","value":"abc"}
1825 },
1826 "schemas":{
1827 "Foo":{"type":"string","example":"hello"}
1828 }
1829 }
1830 }"##;
1831 let ir = parse_str(src).unwrap().spec.unwrap();
1832 let param = &ir.operations[0].path_params[0];
1834 assert_eq!(param.examples.len(), 2);
1835 assert_eq!(param.examples[0].0, "short");
1836 let r0 = param.examples[0].1.value.unwrap() as usize;
1837 assert_eq!(ir.values[r0], forge_ir::Value::s("42"));
1838 assert_eq!(param.examples[1].0, "uuid");
1839 let r1 = param.examples[1].1.value.unwrap() as usize;
1840 assert_eq!(ir.values[r1], forge_ir::Value::s("abc"));
1841 let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1843 assert_eq!(foo.examples.len(), 1);
1844 assert_eq!(foo.examples[0].0, "_default");
1845 let r2 = foo.examples[0].1.value.unwrap() as usize;
1846 assert_eq!(ir.values[r2], forge_ir::Value::s("hello"));
1847 }
1848
1849 #[test]
1850 fn compound_example_survives_via_value_pool() {
1851 let src = r#"{
1852 "openapi":"3.0.3",
1853 "info":{"title":"t","version":"1"},
1854 "paths":{},
1855 "components":{
1856 "schemas":{
1857 "Foo":{"type":"object","example":{"k":"v"}}
1858 }
1859 }
1860 }"#;
1861 let out = parse_str(src).unwrap();
1862 let ir = out.spec.unwrap();
1863 let foo = ir.types.iter().find(|t| t.id == "Foo").cloned().unwrap();
1864 assert_eq!(foo.examples.len(), 1);
1866 assert_eq!(foo.examples[0].0, "_default");
1867 let r = foo.examples[0].1.value.unwrap() as usize;
1868 let resolved = &ir.values[r];
1869 let forge_ir::Value::Object { fields } = resolved else {
1870 panic!("expected object example, got {resolved:?}");
1871 };
1872 assert_eq!(fields.len(), 1);
1873 assert_eq!(fields[0].0, "k");
1874 assert_eq!(ir.values[fields[0].1 as usize], forge_ir::Value::s("v"));
1875 }
1876
1877 #[test]
1878 fn example_with_value_and_external_value_keeps_value() {
1879 let src = r##"{
1880 "openapi":"3.0.3",
1881 "info":{"title":"t","version":"1"},
1882 "paths":{},
1883 "components":{
1884 "examples":{
1885 "Conflict":{
1886 "value":"inline",
1887 "externalValue":"https://example.com/blob"
1888 }
1889 },
1890 "schemas":{
1891 "Foo":{
1892 "type":"string",
1893 "examples":{"a":{"$ref":"#/components/examples/Conflict"}}
1894 }
1895 }
1896 }
1897 }"##;
1898 let out = parse_str(src).unwrap();
1899 let ir = out.spec.as_ref().unwrap();
1900 let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1901 let ex = &foo.examples[0].1;
1902 let r = ex.value.unwrap() as usize;
1903 assert_eq!(ir.values[r], forge_ir::Value::s("inline"));
1904 assert!(ex.external_value.is_none());
1905 assert!(out
1906 .diagnostics
1907 .iter()
1908 .any(|d| d.code == diag::E_EXAMPLE_VALUE_CONFLICT));
1909 }
1910
1911 #[test]
1912 fn item_schema_populates_item_schema_and_type() {
1913 let src = r##"{
1917 "openapi":"3.2.0",
1918 "info":{"title":"t","version":"1"},
1919 "paths":{
1920 "/events":{
1921 "get":{
1922 "operationId":"stream",
1923 "responses":{
1924 "200":{
1925 "description":"jsonl",
1926 "content":{
1927 "application/jsonl":{
1928 "itemSchema":{"$ref":"#/components/schemas/Event"}
1929 }
1930 }
1931 }
1932 }
1933 }
1934 }
1935 },
1936 "components":{
1937 "schemas":{
1938 "Event":{"type":"object","properties":{"id":{"type":"string"}}}
1939 }
1940 }
1941 }"##;
1942 let ir = parse_str(src).unwrap().spec.unwrap();
1943 let op = &ir.operations[0];
1944 let content = &op.responses[0].content[0];
1945 assert_eq!(content.media_type, "application/jsonl");
1946 assert_eq!(content.r#type, "Event");
1947 assert_eq!(content.item_schema.as_deref(), Some("Event"));
1948 }
1949
1950 #[test]
1951 fn schema_only_leaves_item_schema_none() {
1952 let src = r#"{
1954 "openapi":"3.0.3",
1955 "info":{"title":"t","version":"1"},
1956 "paths":{
1957 "/x":{
1958 "get":{
1959 "operationId":"x",
1960 "responses":{
1961 "200":{"description":"ok","content":{
1962 "application/json":{"schema":{"type":"string"}}
1963 }}
1964 }
1965 }
1966 }
1967 }
1968 }"#;
1969 let ir = parse_str(src).unwrap().spec.unwrap();
1970 let content = &ir.operations[0].responses[0].content[0];
1971 assert!(content.item_schema.is_none());
1972 }
1973
1974 #[test]
1975 fn schema_and_item_schema_together_emit_conflict_error() {
1976 let src = r#"{
1977 "openapi":"3.2.0",
1978 "info":{"title":"t","version":"1"},
1979 "paths":{
1980 "/x":{
1981 "get":{
1982 "operationId":"x",
1983 "responses":{
1984 "200":{"description":"ok","content":{
1985 "application/json":{
1986 "schema":{"type":"string"},
1987 "itemSchema":{"type":"string"}
1988 }
1989 }}
1990 }
1991 }
1992 }
1993 }
1994 }"#;
1995 let out = parse_str(src).unwrap();
1996 assert!(out
1997 .diagnostics
1998 .iter()
1999 .any(|d| d.code == diag::E_CONTENT_SCHEMA_CONFLICT));
2000 }
2001
2002 #[test]
2003 fn additional_operations_walk_into_other_method() {
2004 let src = r#"{
2005 "openapi":"3.2.0",
2006 "info":{"title":"t","version":"1"},
2007 "paths":{
2008 "/items":{
2009 "get":{"operationId":"listItems","responses":{"204":{"description":"ok"}}},
2010 "additionalOperations":{
2011 "QUERY":{
2012 "operationId":"queryItems",
2013 "responses":{"204":{"description":"ok"}}
2014 }
2015 }
2016 }
2017 }
2018 }"#;
2019 let ir = parse_str(src).unwrap().spec.unwrap();
2020 let query_op = ir
2021 .operations
2022 .iter()
2023 .find(|o| o.id == "queryItems")
2024 .expect("queryItems present");
2025 assert_eq!(query_op.method, forge_ir::HttpMethod::Other("QUERY".into()));
2026 let list_op = ir.operations.iter().find(|o| o.id == "listItems").unwrap();
2028 assert_eq!(list_op.method, forge_ir::HttpMethod::Get);
2029 }
2030
2031 #[test]
2032 fn additional_operations_method_normalised_to_uppercase() {
2033 let src = r#"{
2038 "openapi":"3.2.0",
2039 "info":{"title":"t","version":"1"},
2040 "paths":{
2041 "/x":{
2042 "additionalOperations":{
2043 "Query":{
2044 "operationId":"qx",
2045 "responses":{"204":{"description":"ok"}}
2046 }
2047 }
2048 }
2049 }
2050 }"#;
2051 let ir = parse_str(src).unwrap().spec.unwrap();
2052 assert_eq!(
2053 ir.operations[0].method,
2054 forge_ir::HttpMethod::Other("QUERY".into())
2055 );
2056 }
2057
2058 #[test]
2059 fn http_method_as_str_returns_wire_form() {
2060 use forge_ir::HttpMethod as M;
2061 assert_eq!(M::Get.as_str(), "GET");
2062 assert_eq!(M::Patch.as_str(), "PATCH");
2063 assert_eq!(M::Other("QUERY".into()).as_str(), "QUERY");
2064 }
2065
2066 #[test]
2067 fn schema_defaults_populate_named_type_and_property() {
2068 let src = r#"{
2069 "openapi":"3.0.3",
2070 "info":{"title":"t","version":"1"},
2071 "paths":{},
2072 "components":{
2073 "schemas":{
2074 "PageSize":{"type":"integer","default":25},
2075 "Pet":{
2076 "type":"object",
2077 "properties":{
2078 "name":{"type":"string","default":"Rex"}
2079 }
2080 }
2081 }
2082 }
2083 }"#;
2084 let ir = parse_str(src).unwrap().spec.unwrap();
2085 let page_size = ir.types.iter().find(|t| t.id == "PageSize").unwrap();
2086 let r = page_size.default.unwrap() as usize;
2087 assert_eq!(ir.values[r], forge_ir::Value::Int { value: 25 });
2088 let pet = ir.types.iter().find(|t| t.id == "Pet").unwrap();
2089 let forge_ir::TypeDef::Object(pet_obj) = &pet.definition else {
2090 panic!("Pet should be object");
2091 };
2092 let name_prop = pet_obj
2093 .properties
2094 .iter()
2095 .find(|p| p.name == "name")
2096 .unwrap();
2097 let r = name_prop.default.unwrap() as usize;
2098 assert_eq!(ir.values[r], forge_ir::Value::s("Rex"));
2099 }
2100
2101 #[test]
2102 fn schema_default_null_round_trips() {
2103 let src = r#"{
2105 "openapi":"3.0.3",
2106 "info":{"title":"t","version":"1"},
2107 "paths":{},
2108 "components":{
2109 "schemas":{
2110 "Empty":{"type":"string","default":null}
2111 }
2112 }
2113 }"#;
2114 let ir = parse_str(src).unwrap().spec.unwrap();
2115 let empty = ir.types.iter().find(|t| t.id == "Empty").unwrap();
2116 let r = empty.default.unwrap() as usize;
2117 assert_eq!(ir.values[r], forge_ir::Value::Null);
2118 }
2119
2120 #[test]
2121 fn schema_compound_default_survives_via_value_pool() {
2122 let src = r#"{
2123 "openapi":"3.0.3",
2124 "info":{"title":"t","version":"1"},
2125 "paths":{},
2126 "components":{
2127 "schemas":{
2128 "Cfg":{"type":"object","default":{"k":"v"}}
2129 }
2130 }
2131 }"#;
2132 let out = parse_str(src).unwrap();
2133 let ir = out.spec.unwrap();
2134 let cfg = ir.types.iter().find(|t| t.id == "Cfg").unwrap();
2135 let r = cfg.default.unwrap() as usize;
2136 let forge_ir::Value::Object { fields } = &ir.values[r] else {
2137 panic!("expected object default");
2138 };
2139 assert_eq!(fields.len(), 1);
2140 assert_eq!(fields[0].0, "k");
2141 assert_eq!(ir.values[fields[0].1 as usize], forge_ir::Value::s("v"));
2142 }
2143
2144 #[test]
2145 fn tags_walk_into_structured_records() {
2146 let src = r#"{
2147 "openapi":"3.2.0",
2148 "info":{"title":"t","version":"1"},
2149 "tags":[
2150 {
2151 "name":"pets",
2152 "summary":"S",
2153 "description":"D",
2154 "kind":"audience",
2155 "externalDocs":{"url":"https://example.com"}
2156 },
2157 {"name":"cats","parent":"pets"}
2158 ],
2159 "paths":{}
2160 }"#;
2161 let ir = parse_str(src).unwrap().spec.unwrap();
2162 assert_eq!(ir.tags[0].name, "cats");
2164 assert_eq!(ir.tags[0].parent.as_deref(), Some("pets"));
2165 assert_eq!(ir.tags[1].name, "pets");
2166 assert_eq!(ir.tags[1].summary.as_deref(), Some("S"));
2167 assert_eq!(ir.tags[1].description.as_deref(), Some("D"));
2168 assert_eq!(ir.tags[1].kind.as_deref(), Some("audience"));
2169 assert_eq!(
2170 ir.tags[1].external_docs.as_ref().unwrap().url,
2171 "https://example.com"
2172 );
2173 }
2174
2175 #[test]
2176 fn tag_parent_dangling_drops_ref_keeps_tag() {
2177 let src = r#"{
2178 "openapi":"3.2.0",
2179 "info":{"title":"t","version":"1"},
2180 "tags":[
2181 {"name":"cats","parent":"no-such-tag"}
2182 ],
2183 "paths":{}
2184 }"#;
2185 let out = parse_str(src).unwrap();
2186 let ir = out.spec.unwrap();
2187 assert_eq!(ir.tags.len(), 1);
2188 assert_eq!(ir.tags[0].name, "cats");
2189 assert!(ir.tags[0].parent.is_none());
2191 assert!(out
2192 .diagnostics
2193 .iter()
2194 .any(|d| d.code == diag::W_TAG_PARENT_DANGLING));
2195 }
2196
2197 #[test]
2198 fn tags_extensions_round_trip() {
2199 let src = r#"{
2200 "openapi":"3.0.3",
2201 "info":{"title":"t","version":"1"},
2202 "tags":[
2203 {"name":"pets","x-priority":5}
2204 ],
2205 "paths":{}
2206 }"#;
2207 let ir = parse_str(src).unwrap().spec.unwrap();
2208 let ext = &ir.tags[0].extensions;
2209 assert_eq!(ext.len(), 1);
2210 assert_eq!(ext[0].0, "x-priority");
2211 }
2212
2213 #[test]
2214 fn operation_servers_resolution_picks_most_specific() {
2215 let src = r#"{
2216 "openapi":"3.0.3",
2217 "info":{"title":"t","version":"1"},
2218 "servers":[{"url":"https://root"}],
2219 "paths":{
2220 "/a":{
2221 "get":{"operationId":"opA","responses":{"204":{"description":"ok"}}}
2222 },
2223 "/b":{
2224 "servers":[{"url":"https://path-b"}],
2225 "get":{"operationId":"opB","responses":{"204":{"description":"ok"}}},
2226 "post":{
2227 "operationId":"opC",
2228 "servers":[{"url":"https://op-c"}],
2229 "responses":{"204":{"description":"ok"}}
2230 }
2231 }
2232 }
2233 }"#;
2234 let ir = parse_str(src).unwrap().spec.unwrap();
2235 let by_id = |id: &str| {
2236 ir.operations
2237 .iter()
2238 .find(|o| o.id == id)
2239 .unwrap_or_else(|| panic!("operation {id} not found"))
2240 };
2241 assert_eq!(by_id("opA").servers[0].url, "https://root");
2242 assert_eq!(by_id("opB").servers[0].url, "https://path-b");
2243 assert_eq!(by_id("opC").servers[0].url, "https://op-c");
2244 }
2245
2246 #[test]
2247 fn operation_servers_empty_when_no_root_or_overrides() {
2248 let src = r#"{
2251 "openapi":"3.0.3",
2252 "info":{"title":"t","version":"1"},
2253 "paths":{
2254 "/x":{"get":{"operationId":"x","responses":{"204":{"description":"ok"}}}}
2255 }
2256 }"#;
2257 let ir = parse_str(src).unwrap().spec.unwrap();
2258 assert!(ir.operations[0].servers.is_empty());
2259 assert!(ir.servers.is_empty());
2260 }
2261
2262 #[test]
2263 fn operation_servers_explicit_empty_array_falls_through_to_root() {
2264 let src = r#"{
2269 "openapi":"3.0.3",
2270 "info":{"title":"t","version":"1"},
2271 "servers":[{"url":"https://root"}],
2272 "paths":{
2273 "/x":{"get":{
2274 "operationId":"x",
2275 "servers":[],
2276 "responses":{"204":{"description":"ok"}}
2277 }}
2278 }
2279 }"#;
2280 let ir = parse_str(src).unwrap().spec.unwrap();
2281 assert_eq!(ir.operations[0].servers[0].url, "https://root");
2282 }
2283
2284 #[test]
2285 fn external_docs_absent_leaves_field_none() {
2286 let src = r#"{
2287 "openapi":"3.0.3",
2288 "info":{"title":"t","version":"1"},
2289 "paths":{}
2290 }"#;
2291 let ir = parse_str(src).unwrap().spec.unwrap();
2292 assert!(ir.external_docs.is_none());
2293 }
2294
2295 #[test]
2296 fn info_license_name_only_round_trips() {
2297 let src = r#"{
2300 "openapi":"3.0.0",
2301 "info":{
2302 "title":"t",
2303 "version":"1",
2304 "license":{"name":"MIT"}
2305 },
2306 "paths":{}
2307 }"#;
2308 let ir = parse_str(src).unwrap().spec.unwrap();
2309 assert_eq!(ir.info.license_name.as_deref(), Some("MIT"));
2310 assert!(ir.info.license_url.is_none());
2311 assert!(ir.info.license_identifier.is_none());
2312 }
2313
2314 #[test]
2315 fn extensions_populate_on_every_specification_object() {
2316 use forge_ir::{SecuritySchemeKind, TypeDef};
2317 let src = r##"{
2322 "openapi":"3.0.3",
2323 "info":{
2324 "title":"t",
2325 "version":"1",
2326 "x-info":"info-ext"
2327 },
2328 "servers":[{
2329 "url":"https://api.example.com/{tier}",
2330 "x-server":"server-ext",
2331 "variables":{
2332 "tier":{
2333 "default":"v1",
2334 "x-var":"var-ext"
2335 }
2336 }
2337 }],
2338 "paths":{
2339 "/things":{
2340 "post":{
2341 "operationId":"create",
2342 "parameters":[{
2343 "name":"q",
2344 "in":"query",
2345 "schema":{"type":"string"},
2346 "x-param":"param-ext"
2347 }],
2348 "requestBody":{
2349 "x-body":"body-ext",
2350 "content":{
2351 "multipart/form-data":{
2352 "x-content":"content-ext",
2353 "schema":{"$ref":"#/components/schemas/Thing"},
2354 "encoding":{
2355 "name":{
2356 "contentType":"text/plain",
2357 "x-encoding":"encoding-ext"
2358 }
2359 }
2360 }
2361 }
2362 },
2363 "responses":{
2364 "200":{
2365 "description":"ok",
2366 "x-response":"response-ext"
2367 }
2368 }
2369 }
2370 }
2371 },
2372 "components":{
2373 "schemas":{
2374 "Thing":{
2375 "type":"object",
2376 "x-schema":"schema-ext",
2377 "properties":{
2378 "name":{
2379 "type":"string",
2380 "x-prop":"prop-ext"
2381 }
2382 }
2383 }
2384 },
2385 "securitySchemes":{
2386 "OAuth":{
2387 "type":"oauth2",
2388 "x-scheme":"scheme-ext",
2389 "flows":{
2390 "authorizationCode":{
2391 "authorizationUrl":"https://a",
2392 "tokenUrl":"https://t",
2393 "scopes":{},
2394 "x-flow":"flow-ext"
2395 }
2396 }
2397 }
2398 }
2399 }
2400 }"##;
2401 let ir = parse_str(src).unwrap().spec.unwrap();
2402
2403 let exts = &ir.info.extensions;
2405 assert!(
2406 exts.iter().any(|(k, _)| k == "x-info"),
2407 "info.extensions missing x-info: {exts:?}"
2408 );
2409
2410 let server = &ir.servers[0];
2412 assert!(server.extensions.iter().any(|(k, _)| k == "x-server"));
2413 let (_var_name, var) = &server.variables[0];
2414 assert!(var.extensions.iter().any(|(k, _)| k == "x-var"));
2415
2416 let thing = ir.types.iter().find(|t| t.id == "Thing").unwrap();
2418 assert!(thing.extensions.iter().any(|(k, _)| k == "x-schema"));
2419 let TypeDef::Object(obj) = &thing.definition else {
2420 panic!("expected object")
2421 };
2422 let name_prop = obj.properties.iter().find(|p| p.name == "name").unwrap();
2423 assert!(name_prop.extensions.iter().any(|(k, _)| k == "x-prop"));
2424
2425 let op = &ir.operations[0];
2427 let p = &op.query_params[0];
2428 assert!(p.extensions.iter().any(|(k, _)| k == "x-param"));
2429 let body = op.request_body.as_ref().unwrap();
2430 assert!(body.extensions.iter().any(|(k, _)| k == "x-body"));
2431 let content = &body.content[0];
2432 assert!(content.extensions.iter().any(|(k, _)| k == "x-content"));
2433 let (_enc_name, enc) = &content.encoding[0];
2434 assert!(enc.extensions.iter().any(|(k, _)| k == "x-encoding"));
2435 let resp = &op.responses[0];
2436 assert!(resp.extensions.iter().any(|(k, _)| k == "x-response"));
2437
2438 let scheme = ir
2440 .security_schemes
2441 .iter()
2442 .find(|s| s.id == "OAuth")
2443 .unwrap();
2444 assert!(scheme.extensions.iter().any(|(k, _)| k == "x-scheme"));
2445 let SecuritySchemeKind::Oauth2(o) = &scheme.kind else {
2446 panic!("expected oauth2");
2447 };
2448 let flow = &o.flows[0];
2449 assert!(flow.extensions.iter().any(|(k, _)| k == "x-flow"));
2450 }
2451
2452 #[test]
2453 fn compound_extensions_survive_via_value_pool() {
2454 let src = r#"{
2458 "openapi":"3.0.3",
2459 "info":{
2460 "title":"t",
2461 "version":"1",
2462 "x-array":[1,2,3]
2463 },
2464 "paths":{}
2465 }"#;
2466 let ir = parse_str(src).unwrap().spec.unwrap();
2467 let entry = ir
2468 .info
2469 .extensions
2470 .iter()
2471 .find(|(k, _)| k == "x-array")
2472 .expect("x-array extension survives");
2473 let r = entry.1 as usize;
2474 let forge_ir::Value::List { items } = &ir.values[r] else {
2475 panic!("expected list, got {:?}", ir.values[r]);
2476 };
2477 assert_eq!(items.len(), 3);
2478 }
2479
2480 #[test]
2481 fn server_name_3_2_round_trips() {
2482 let src = r#"{
2485 "openapi":"3.2.0",
2486 "info":{"title":"t","version":"1"},
2487 "servers":[
2488 {"url":"https://api.example.com","name":"production"},
2489 {"url":"https://staging.example.com"}
2490 ],
2491 "paths":{}
2492 }"#;
2493 let ir = parse_str(src).unwrap().spec.unwrap();
2494 assert_eq!(ir.servers[0].name.as_deref(), Some("production"));
2495 assert!(ir.servers[1].name.is_none());
2496 }
2497
2498 #[test]
2499 fn parameter_querystring_3_2_routes_to_new_bucket() {
2500 let src = r#"{
2504 "openapi":"3.2.0",
2505 "info":{"title":"t","version":"1"},
2506 "paths":{
2507 "/search":{"get":{
2508 "operationId":"search",
2509 "parameters":[
2510 {"name":"raw","in":"querystring","schema":{"type":"string"}}
2511 ],
2512 "responses":{"200":{"description":"ok"}}
2513 }}
2514 }
2515 }"#;
2516 let ir = parse_str(src).unwrap().spec.unwrap();
2517 let op = &ir.operations[0];
2518 assert!(op.query_params.is_empty(), "must not land in query_params");
2519 assert_eq!(op.querystring_params.len(), 1);
2520 assert_eq!(op.querystring_params[0].name, "raw");
2521 }
2522
2523 #[test]
2524 fn example_data_value_serialized_value_3_2() {
2525 let src = r##"{
2528 "openapi":"3.2.0",
2529 "info":{"title":"t","version":"1"},
2530 "paths":{},
2531 "components":{
2532 "schemas":{
2533 "Thing":{
2534 "type":"string",
2535 "examples":[
2536 {"summary":"alice","dataValue":"alice","serializedValue":"\"alice\""}
2537 ]
2538 }
2539 }
2540 }
2541 }"##;
2542 let src2 = r##"{
2545 "openapi":"3.2.0",
2546 "info":{"title":"t","version":"1"},
2547 "paths":{
2548 "/thing":{"post":{
2549 "operationId":"create",
2550 "requestBody":{"content":{"application/json":{
2551 "schema":{"type":"string"},
2552 "examples":{
2553 "alice":{"dataValue":"alice","serializedValue":"\"alice\""}
2554 }
2555 }}},
2556 "responses":{"200":{"description":"ok"}}
2557 }}
2558 }
2559 }"##;
2560 let _ = src; let ir = parse_str(src2).unwrap().spec.unwrap();
2562 let body = ir.operations[0].request_body.as_ref().unwrap();
2563 let example = &body.content[0].examples[0].1;
2564 let r = example.data_value.unwrap() as usize;
2565 assert_eq!(ir.values[r], forge_ir::Value::s("alice"));
2566 assert_eq!(example.serialized_value.as_deref(), Some("\"alice\""));
2567 }
2568
2569 #[test]
2570 fn xml_text_ordered_3_2() {
2571 let src = r#"{
2573 "openapi":"3.2.0",
2574 "info":{"title":"t","version":"1"},
2575 "paths":{},
2576 "components":{
2577 "schemas":{
2578 "Title":{"type":"string","xml":{"text":true}},
2579 "Steps":{"type":"array","items":{"type":"string"},"xml":{"wrapped":true,"ordered":true}}
2580 }
2581 }
2582 }"#;
2583 let ir = parse_str(src).unwrap().spec.unwrap();
2584 let title = ir.types.iter().find(|t| t.id == "Title").unwrap();
2585 let title_xml = title.xml.as_ref().unwrap();
2586 assert!(title_xml.text);
2587 assert!(!title_xml.ordered);
2588
2589 let steps = ir.types.iter().find(|t| t.id == "Steps").unwrap();
2590 let steps_xml = steps.xml.as_ref().unwrap();
2591 assert!(steps_xml.ordered);
2592 assert!(!steps_xml.text);
2593 }
2594
2595 #[test]
2596 fn mutual_tls_security_scheme_round_trips() {
2597 use forge_ir::SecuritySchemeKind;
2598 let src = r#"{
2599 "openapi":"3.0.3",
2600 "info":{"title":"t","version":"1"},
2601 "paths":{},
2602 "components":{"securitySchemes":{
2603 "mtls":{"type":"mutualTLS","description":"client-cert auth"}
2604 }}
2605 }"#;
2606 let ir = parse_str(src).unwrap().spec.unwrap();
2607 let scheme = &ir.security_schemes[0];
2608 assert_eq!(scheme.id, "mtls");
2609 assert!(matches!(scheme.kind, SecuritySchemeKind::MutualTls));
2610 assert_eq!(scheme.documentation.as_deref(), Some("client-cert auth"));
2611 }
2612
2613 #[test]
2614 fn oauth2_all_four_flows_succeed() {
2615 use forge_ir::{OAuth2FlowKind, SecuritySchemeKind};
2616 let src = r#"{
2617 "openapi":"3.0.3",
2618 "info":{"title":"t","version":"1"},
2619 "paths":{},
2620 "components":{"securitySchemes":{
2621 "auth":{"type":"oauth2","flows":{
2622 "implicit":{
2623 "authorizationUrl":"https://a/auth",
2624 "scopes":{"read":"r"}
2625 },
2626 "password":{
2627 "tokenUrl":"https://a/token",
2628 "scopes":{"read":"r"}
2629 },
2630 "clientCredentials":{
2631 "tokenUrl":"https://a/token",
2632 "scopes":{"read":"r"}
2633 },
2634 "authorizationCode":{
2635 "authorizationUrl":"https://a/auth",
2636 "tokenUrl":"https://a/token",
2637 "scopes":{"read":"r"}
2638 }
2639 }}
2640 }}
2641 }"#;
2642 let ir = parse_str(src).unwrap().spec.unwrap();
2643 let scheme = &ir.security_schemes[0];
2644 let SecuritySchemeKind::Oauth2(o) = &scheme.kind else {
2645 panic!("expected oauth2 kind");
2646 };
2647 assert_eq!(o.flows.len(), 4, "all four flows surface");
2648 let kinds: Vec<OAuth2FlowKind> = o.flows.iter().map(|f| f.kind).collect();
2649 assert!(kinds.contains(&OAuth2FlowKind::Implicit));
2650 assert!(kinds.contains(&OAuth2FlowKind::Password));
2651 assert!(kinds.contains(&OAuth2FlowKind::ClientCredentials));
2652 assert!(kinds.contains(&OAuth2FlowKind::AuthorizationCode));
2653 }
2654
2655 #[test]
2656 fn oauth2_missing_required_url_errors() {
2657 let src = r#"{
2660 "openapi":"3.0.3",
2661 "info":{"title":"t","version":"1"},
2662 "paths":{},
2663 "components":{"securitySchemes":{
2664 "auth":{"type":"oauth2","flows":{
2665 "password":{"scopes":{"read":"r"}}
2666 }}
2667 }}
2668 }"#;
2669 let out = parse_str(src).unwrap();
2670 assert!(
2671 out.diagnostics
2672 .iter()
2673 .any(|d| d.code == diag::E_OAUTH2_MISSING_URL),
2674 "expected E-OAUTH2-MISSING-URL"
2675 );
2676 }
2677
2678 #[test]
2679 fn content_encoding_keywords_round_trip() {
2680 use forge_ir::TypeDef;
2685 let src = r#"{
2686 "openapi":"3.2.0",
2687 "info":{"title":"t","version":"1"},
2688 "paths":{},
2689 "components":{"schemas":{
2690 "Avatar":{
2691 "type":"string",
2692 "contentEncoding":"base64",
2693 "contentMediaType":"image/png"
2694 },
2695 "Embedded":{
2696 "type":"string",
2697 "contentMediaType":"application/json",
2698 "contentSchema":{
2699 "type":"object",
2700 "properties":{"id":{"type":"string"}}
2701 }
2702 }
2703 }}
2704 }"#;
2705 let ir = parse_str(src).unwrap().spec.unwrap();
2706
2707 let avatar = ir.types.iter().find(|t| t.id == "Avatar").unwrap();
2708 let TypeDef::Primitive(p) = &avatar.definition else {
2709 panic!("expected primitive");
2710 };
2711 assert_eq!(p.constraints.content_encoding.as_deref(), Some("base64"));
2712 assert_eq!(
2713 p.constraints.content_media_type.as_deref(),
2714 Some("image/png")
2715 );
2716 assert!(p.constraints.content_schema.is_none());
2717
2718 let embedded = ir.types.iter().find(|t| t.id == "Embedded").unwrap();
2719 let TypeDef::Primitive(p) = &embedded.definition else {
2720 panic!("expected primitive");
2721 };
2722 assert_eq!(
2723 p.constraints.content_media_type.as_deref(),
2724 Some("application/json")
2725 );
2726 let cs_ref = p
2727 .constraints
2728 .content_schema
2729 .as_deref()
2730 .expect("content_schema set");
2731 assert!(ir.types.iter().any(|t| t.id == cs_ref));
2733 }
2734
2735 #[test]
2736 fn components_media_types_pool_resolves_refs() {
2737 let src = r##"{
2741 "openapi":"3.2.0",
2742 "info":{"title":"t","version":"1"},
2743 "paths":{
2744 "/things":{"post":{
2745 "operationId":"create",
2746 "requestBody":{"content":{
2747 "application/json":{"$ref":"#/components/mediaTypes/ThingJson"}
2748 }},
2749 "responses":{"204":{"description":"ok"}}
2750 }}
2751 },
2752 "components":{
2753 "schemas":{
2754 "Thing":{"type":"object","properties":{"id":{"type":"string"}}}
2755 },
2756 "mediaTypes":{
2757 "ThingJson":{"schema":{"$ref":"#/components/schemas/Thing"}},
2758 "Unused":{"schema":{"type":"string"}}
2759 }
2760 }
2761 }"##;
2762 let out = parse_str(src).unwrap();
2763 let ir = out.spec.unwrap();
2764 let body = ir.operations[0].request_body.as_ref().unwrap();
2765 assert_eq!(body.content[0].r#type, "Thing");
2767 assert!(
2769 out.diagnostics
2770 .iter()
2771 .any(|d| d.code == diag::W_COMPONENT_MEDIA_TYPE_UNUSED
2772 && d.message.contains("Unused")),
2773 "expected W-COMPONENT-MEDIA-TYPE-UNUSED for `Unused`"
2774 );
2775 assert!(
2777 !out.diagnostics
2778 .iter()
2779 .any(|d| d.code == diag::W_COMPONENT_MEDIA_TYPE_UNUSED
2780 && d.message.contains("ThingJson")),
2781 "ThingJson is referenced; should not warn"
2782 );
2783 }
2784
2785 #[test]
2786 fn json_schema_deferred_keywords_warn_not_error() {
2787 let src = r#"{
2792 "openapi":"3.1.0",
2793 "info":{"title":"t","version":"1"},
2794 "paths":{},
2795 "components":{"schemas":{
2796 "Bad":{
2797 "type":"object",
2798 "dependentRequired":{"a":["b"]},
2799 "unevaluatedProperties":false,
2800 "properties":{"id":{"type":"string"}}
2801 }
2802 }}
2803 }"#;
2804 let out = parse_str(src).unwrap();
2805 let ir = out.spec.expect("spec parses despite deferred keywords");
2806 assert!(ir.types.iter().any(|t| t.id == "Bad"));
2809 let warns: Vec<&str> = out
2811 .diagnostics
2812 .iter()
2813 .filter(|d| d.severity == forge_ir::Severity::Warning)
2814 .map(|d| d.code.as_str())
2815 .collect();
2816 assert!(
2817 warns.contains(&diag::W_DEPENDENT_REQUIRED_DROPPED),
2818 "expected W-DEPENDENT-REQUIRED-DROPPED, got {warns:?}"
2819 );
2820 assert!(
2821 warns.contains(&diag::W_UNEVALUATED_PROPERTIES_DROPPED),
2822 "expected W-UNEVALUATED-PROPERTIES-DROPPED, got {warns:?}"
2823 );
2824 let errs: Vec<&str> = out
2826 .diagnostics
2827 .iter()
2828 .filter(|d| d.severity == forge_ir::Severity::Error)
2829 .map(|d| d.code.as_str())
2830 .collect();
2831 assert!(errs.is_empty(), "no errors expected, got {errs:?}");
2832 }
2833
2834 #[test]
2835 fn root_json_schema_dialect_and_self_round_trip() {
2836 let src = r##"{
2838 "openapi":"3.2.0",
2839 "$self":"https://example.com/api.json",
2840 "jsonSchemaDialect":"https://json-schema.org/draft/2020-12/schema",
2841 "info":{"title":"t","version":"1"},
2842 "paths":{}
2843 }"##;
2844 let ir = parse_str(src).unwrap().spec.unwrap();
2845 assert_eq!(
2846 ir.json_schema_dialect.as_deref(),
2847 Some("https://json-schema.org/draft/2020-12/schema")
2848 );
2849 assert_eq!(ir.self_url.as_deref(), Some("https://example.com/api.json"));
2850 }
2851
2852 #[test]
2853 fn header_style_explode_round_trip() {
2854 use forge_ir::ParameterStyle;
2857 let src = r#"{
2858 "openapi":"3.0.3",
2859 "info":{"title":"t","version":"1"},
2860 "paths":{"/x":{"get":{
2861 "operationId":"x",
2862 "responses":{"200":{
2863 "description":"ok",
2864 "headers":{
2865 "X-Rate":{
2866 "schema":{"type":"integer"},
2867 "style":"simple",
2868 "explode":true,
2869 "allowReserved":false,
2870 "allowEmptyValue":false
2871 }
2872 }
2873 }}
2874 }}}
2875 }"#;
2876 let ir = parse_str(src).unwrap().spec.unwrap();
2877 let resp = &ir.operations[0].responses[0];
2878 let (_name, h) = &resp.headers[0];
2879 assert_eq!(h.style, Some(ParameterStyle::Simple));
2880 assert!(h.explode);
2881 assert!(!h.allow_reserved);
2882 assert!(!h.allow_empty_value);
2883 }
2884
2885 #[test]
2886 fn ref_siblings_3_1_plus_merge_onto_target() {
2887 let src = r##"{
2891 "openapi":"3.2.0",
2892 "info":{"title":"t","version":"1"},
2893 "paths":{"/x":{"get":{
2894 "operationId":"x",
2895 "responses":{"200":{
2896 "$ref":"#/components/responses/Shared",
2897 "description":"per-call override"
2898 }}
2899 }}},
2900 "components":{"responses":{
2901 "Shared":{
2902 "description":"shared default",
2903 "content":{"application/json":{"schema":{"type":"string"}}}
2904 }
2905 }}
2906 }"##;
2907 let ir = parse_str(src).unwrap().spec.unwrap();
2908 let resp = &ir.operations[0].responses[0];
2909 assert_eq!(
2910 resp.documentation.as_deref(),
2911 Some("per-call override"),
2912 "the sibling `description` wins over the shared default"
2913 );
2914 }
2915
2916 #[test]
2917 fn primitive_kind_carries_only_jsonschema_type_values() {
2918 use forge_ir::{PrimitiveKind, TypeDef};
2923 let src = r#"{
2924 "openapi":"3.0.3",
2925 "info":{"title":"t","version":"1"},
2926 "paths":{},
2927 "components":{"schemas":{
2928 "Plain": {"type":"string"},
2929 "Stamp": {"type":"string","format":"date-time"},
2930 "Mail": {"type":"string","format":"email"},
2931 "Avatar": {"type":"string","format":"byte"},
2932 "Tally": {"type":"integer","format":"int32"},
2933 "Big": {"type":"integer","format":"int64"},
2934 "Money": {"type":"string","format":"decimal"},
2935 "Flag": {"type":"boolean"}
2936 }}
2937 }"#;
2938 let ir = parse_str(src).unwrap().spec.unwrap();
2939 let prim = |id: &str| -> (PrimitiveKind, Option<String>) {
2940 let nt = ir.types.iter().find(|t| t.id == id).unwrap();
2941 let TypeDef::Primitive(p) = &nt.definition else {
2942 panic!("{id} not primitive");
2943 };
2944 (p.kind, p.constraints.format_extension.clone())
2945 };
2946 assert_eq!(prim("Plain"), (PrimitiveKind::String, None));
2947 assert_eq!(
2948 prim("Stamp"),
2949 (PrimitiveKind::String, Some("date-time".into()))
2950 );
2951 assert_eq!(prim("Mail"), (PrimitiveKind::String, Some("email".into())));
2952 assert_eq!(prim("Avatar"), (PrimitiveKind::String, Some("byte".into())));
2953 assert_eq!(
2954 prim("Tally"),
2955 (PrimitiveKind::Integer, Some("int32".into()))
2956 );
2957 assert_eq!(prim("Big"), (PrimitiveKind::Integer, Some("int64".into())));
2958 assert_eq!(
2959 prim("Money"),
2960 (PrimitiveKind::String, Some("decimal".into()))
2961 );
2962 assert_eq!(prim("Flag"), (PrimitiveKind::Bool, None));
2963 }
2964}