use anyhow::{Context, Result, anyhow, bail};
use clap::ValueEnum;
use roas::loader::Loader;
use roas::validation::{Error as ValidationError, Options, Validate};
use roas::{v2, v3_0, v3_1, v3_2};
use serde_json::Value;
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum SpecVersion {
#[value(name = "v2", alias = "2", alias = "2.0", alias = "swagger")]
V2,
#[value(name = "v3_0", alias = "3.0", alias = "v3.0")]
V3_0,
#[value(name = "v3_1", alias = "3.1", alias = "v3.1")]
V3_1,
#[value(name = "v3_2", alias = "3.2", alias = "v3.2")]
V3_2,
}
impl SpecVersion {
pub fn label(self) -> &'static str {
match self {
SpecVersion::V2 => "OpenAPI 2.0",
SpecVersion::V3_0 => "OpenAPI 3.0",
SpecVersion::V3_1 => "OpenAPI 3.1",
SpecVersion::V3_2 => "OpenAPI 3.2",
}
}
}
pub enum DetectedSpec {
V2(v2::spec::Spec),
V3_0(v3_0::spec::Spec),
V3_1(v3_1::spec::Spec),
V3_2(v3_2::spec::Spec),
}
impl DetectedSpec {
pub fn version(&self) -> SpecVersion {
match self {
DetectedSpec::V2(_) => SpecVersion::V2,
DetectedSpec::V3_0(_) => SpecVersion::V3_0,
DetectedSpec::V3_1(_) => SpecVersion::V3_1,
DetectedSpec::V3_2(_) => SpecVersion::V3_2,
}
}
pub fn label(&self) -> &'static str {
self.version().label()
}
pub fn validate(
&self,
options: enumset::EnumSet<Options>,
loader: Option<&mut Loader>,
) -> Result<(), ValidationError> {
match self {
DetectedSpec::V2(s) => s.validate(options, loader),
DetectedSpec::V3_0(s) => s.validate(options, loader),
DetectedSpec::V3_1(s) => s.validate(options, loader),
DetectedSpec::V3_2(s) => s.validate(options, loader),
}
}
pub fn convert_to(self, target: SpecVersion) -> Result<Value> {
match (self, target) {
(DetectedSpec::V2(s), SpecVersion::V2) => to_value("v2", &s),
(DetectedSpec::V3_0(s), SpecVersion::V3_0) => to_value("v3_0", &s),
(DetectedSpec::V3_1(s), SpecVersion::V3_1) => to_value("v3_1", &s),
(DetectedSpec::V3_2(s), SpecVersion::V3_2) => to_value("v3_2", &s),
(DetectedSpec::V2(s), SpecVersion::V3_0) => {
to_value("v3_0", &v3_0::spec::Spec::from(s))
}
(DetectedSpec::V2(s), SpecVersion::V3_1) => {
let v30 = v3_0::spec::Spec::from(s);
to_value("v3_1", &v3_1::spec::Spec::from(v30))
}
(DetectedSpec::V2(s), SpecVersion::V3_2) => {
let v30 = v3_0::spec::Spec::from(s);
let v31 = v3_1::spec::Spec::from(v30);
to_value("v3_2", &v3_2::spec::Spec::from(v31))
}
(DetectedSpec::V3_0(s), SpecVersion::V3_1) => {
to_value("v3_1", &v3_1::spec::Spec::from(s))
}
(DetectedSpec::V3_0(s), SpecVersion::V3_2) => {
let v31 = v3_1::spec::Spec::from(s);
to_value("v3_2", &v3_2::spec::Spec::from(v31))
}
(DetectedSpec::V3_1(s), SpecVersion::V3_2) => {
to_value("v3_2", &v3_2::spec::Spec::from(s))
}
(from, to) => bail!(
"unsupported conversion: {} → {}",
DetectedSpec::label_of(&from),
to.label(),
),
}
}
fn label_of(spec: &DetectedSpec) -> &'static str {
spec.label()
}
}
fn to_value<T: serde::Serialize>(version_tag: &str, spec: &T) -> Result<Value> {
serde_json::to_value(spec).with_context(|| format!("serialising {version_tag} spec"))
}
pub fn parse_value(raw: &str, is_yaml: bool) -> Result<Value> {
if is_yaml {
serde_yaml_ng::from_str(raw).context("parsing YAML")
} else {
serde_json::from_str(raw).context("parsing JSON")
}
}
pub fn detect_or_use(forced: Option<SpecVersion>, value: Value) -> Result<DetectedSpec> {
let version = match forced {
Some(v) => v,
None => detect(&value)?,
};
parse_as(version, value)
}
fn detect(value: &Value) -> Result<SpecVersion> {
let obj = value
.as_object()
.ok_or_else(|| anyhow!("spec must be an object at the top level"))?;
if let Some(swagger) = obj.get("swagger").and_then(|v| v.as_str()) {
let (major, _) = parse_version(swagger)
.ok_or_else(|| anyhow!("unsupported swagger version: {swagger}"))?;
if major == 2 {
return Ok(SpecVersion::V2);
}
bail!("unsupported swagger version: {swagger}");
}
if let Some(openapi) = obj.get("openapi").and_then(|v| v.as_str()) {
let (major, minor) = parse_version(openapi)
.ok_or_else(|| anyhow!("unsupported openapi version: {openapi}"))?;
return match (major, minor) {
(3, 0) => Ok(SpecVersion::V3_0),
(3, 1) => Ok(SpecVersion::V3_1),
(3, 2) => Ok(SpecVersion::V3_2),
_ => Err(anyhow!("unsupported openapi version: {openapi}")),
};
}
bail!("could not detect spec version: no `openapi` or `swagger` field at top level")
}
fn parse_version(s: &str) -> Option<(u32, u32)> {
let mut parts = s.splitn(3, '.');
let major = parts.next()?.parse::<u32>().ok()?;
let minor_raw = parts.next()?;
let minor_end = minor_raw
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(minor_raw.len());
let minor = minor_raw.get(..minor_end)?.parse::<u32>().ok()?;
Some((major, minor))
}
fn parse_as(version: SpecVersion, value: Value) -> Result<DetectedSpec> {
Ok(match version {
SpecVersion::V2 => {
DetectedSpec::V2(serde_json::from_value(value).context("deserialising as OpenAPI 2.0")?)
}
SpecVersion::V3_0 => DetectedSpec::V3_0(
serde_json::from_value(value).context("deserialising as OpenAPI 3.0")?,
),
SpecVersion::V3_1 => DetectedSpec::V3_1(
serde_json::from_value(value).context("deserialising as OpenAPI 3.1")?,
),
SpecVersion::V3_2 => DetectedSpec::V3_2(
serde_json::from_value(value).context("deserialising as OpenAPI 3.2")?,
),
})
}
pub fn path_looks_like_yaml(path: &std::path::Path) -> bool {
matches!(
path.extension().and_then(|s| s.to_str()),
Some("yaml" | "yml" | "YAML" | "YML"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_version_extracts_major_minor() {
assert_eq!(parse_version("3.2.0"), Some((3, 2)));
assert_eq!(parse_version("3.10"), Some((3, 10)));
assert_eq!(parse_version("3.10.0-rc1"), Some((3, 10)));
assert_eq!(parse_version("2.0"), Some((2, 0)));
assert_eq!(parse_version("12.345.6"), Some((12, 345)));
}
#[test]
fn parse_version_rejects_malformed() {
assert_eq!(parse_version(""), None);
assert_eq!(parse_version("3"), None);
assert_eq!(parse_version("v3.2"), None);
assert_eq!(parse_version("3.x"), None);
}
#[test]
fn detect_distinguishes_3_1_from_3_10() {
let raw_3_1 = r#"{"openapi":"3.1.0","info":{"title":"x","version":"1"},"paths":{}}"#;
let raw_3_10 = r#"{"openapi":"3.10.0","info":{"title":"x","version":"1"},"paths":{}}"#;
let v31: Value = serde_json::from_str(raw_3_1).unwrap();
let v310: Value = serde_json::from_str(raw_3_10).unwrap();
assert!(matches!(detect(&v31).unwrap(), SpecVersion::V3_1));
let err = detect(&v310).unwrap_err().to_string();
assert!(err.contains("unsupported openapi version"), "got: {err}");
}
#[test]
fn detect_v2_via_swagger_field() {
let v: Value = serde_json::from_str(r#"{"swagger":"2.0"}"#).unwrap();
assert_eq!(detect(&v).unwrap(), SpecVersion::V2);
}
#[test]
fn detect_v3_0_v3_1_v3_2_via_openapi_field() {
let cases = [
(r#"{"openapi":"3.0.4"}"#, SpecVersion::V3_0),
(r#"{"openapi":"3.1.2"}"#, SpecVersion::V3_1),
(r#"{"openapi":"3.2.0"}"#, SpecVersion::V3_2),
];
for (raw, expected) in cases {
let v: Value = serde_json::from_str(raw).unwrap();
assert_eq!(detect(&v).unwrap(), expected, "input was {raw}");
}
}
#[test]
fn detect_rejects_unsupported_swagger_major() {
let v: Value = serde_json::from_str(r#"{"swagger":"1.2"}"#).unwrap();
let err = detect(&v).unwrap_err().to_string();
assert!(err.contains("unsupported swagger version"), "got: {err}",);
}
#[test]
fn detect_rejects_malformed_swagger() {
let v: Value = serde_json::from_str(r#"{"swagger":"not-a-version"}"#).unwrap();
let err = detect(&v).unwrap_err().to_string();
assert!(err.contains("unsupported swagger version"), "got: {err}",);
}
#[test]
fn detect_rejects_unsupported_openapi_major() {
let v: Value = serde_json::from_str(r#"{"openapi":"4.0.0"}"#).unwrap();
let err = detect(&v).unwrap_err().to_string();
assert!(err.contains("unsupported openapi version"), "got: {err}",);
}
#[test]
fn detect_rejects_document_without_version_field() {
let v: Value = serde_json::from_str(r#"{"info":{"title":"x"}}"#).unwrap();
let err = detect(&v).unwrap_err().to_string();
assert!(err.contains("could not detect spec version"), "got: {err}",);
}
#[test]
fn detect_rejects_non_object_root() {
let v: Value = serde_json::from_str(r#"[]"#).unwrap();
let err = detect(&v).unwrap_err().to_string();
assert!(err.contains("object at the top level"), "got: {err}");
}
#[test]
fn parse_value_handles_both_formats() {
let json = r#"{"hello":"world"}"#;
let yaml = "hello: world\n";
assert_eq!(
parse_value(json, false).unwrap(),
serde_json::json!({"hello":"world"})
);
assert_eq!(
parse_value(yaml, true).unwrap(),
serde_json::json!({"hello":"world"})
);
}
#[test]
fn path_looks_like_yaml_sniffs_extensions() {
use std::path::Path;
assert!(path_looks_like_yaml(Path::new("spec.yaml")));
assert!(path_looks_like_yaml(Path::new("spec.yml")));
assert!(path_looks_like_yaml(Path::new("spec.YAML")));
assert!(!path_looks_like_yaml(Path::new("spec.json")));
assert!(!path_looks_like_yaml(Path::new("spec")));
}
#[test]
fn parse_value_yaml_format_surfaces_yaml_parser_error() {
let err = parse_value("key:\n\tvalue: oops\n", true)
.unwrap_err()
.to_string();
assert!(err.contains("parsing YAML"), "got: {err}");
}
#[test]
fn parse_value_json_format_surfaces_json_parser_error() {
let err = parse_value("@@@ not json", false).unwrap_err().to_string();
assert!(err.contains("parsing JSON"), "got: {err}");
}
#[test]
fn detect_or_use_forced_skips_detection_and_uses_target() {
let v: Value = serde_json::from_str(
r#"{"openapi":"3.2.0","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.unwrap();
let s = detect_or_use(Some(SpecVersion::V3_2), v).unwrap();
assert_eq!(s.version(), SpecVersion::V3_2);
assert_eq!(s.label(), "OpenAPI 3.2");
}
#[test]
fn detect_or_use_auto_detects_when_unforced() {
let v: Value = serde_json::from_str(
r#"{"openapi":"3.0.4","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.unwrap();
let s = detect_or_use(None, v).unwrap();
assert_eq!(s.version(), SpecVersion::V3_0);
}
fn assert_major_minor(out: &Value, want_prefix: &str) {
let got = out["openapi"].as_str().expect("openapi must be a string");
assert!(
got.starts_with(want_prefix),
"expected openapi to start with {want_prefix}, got {got}",
);
}
#[test]
fn convert_to_same_version_serialises_as_is() {
let v: Value = serde_json::from_str(
r#"{"openapi":"3.2.0","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.unwrap();
let s = detect_or_use(None, v).unwrap();
let out = s.convert_to(SpecVersion::V3_2).unwrap();
assert_major_minor(&out, "3.2");
}
#[test]
fn convert_to_chains_through_intermediate_versions() {
let v: Value = serde_json::from_str(
r#"{"swagger":"2.0","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.unwrap();
let s = detect_or_use(None, v).unwrap();
let out = s.convert_to(SpecVersion::V3_2).unwrap();
assert_major_minor(&out, "3.2");
}
#[test]
fn convert_to_single_step_upconvert_changes_minor() {
let v: Value = serde_json::from_str(
r#"{"openapi":"3.0.4","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.unwrap();
let s = detect_or_use(None, v).unwrap();
let out = s.convert_to(SpecVersion::V3_1).unwrap();
assert_major_minor(&out, "3.1");
}
#[test]
fn spec_version_label_round_trip() {
for (v, expected) in [
(SpecVersion::V2, "OpenAPI 2.0"),
(SpecVersion::V3_0, "OpenAPI 3.0"),
(SpecVersion::V3_1, "OpenAPI 3.1"),
(SpecVersion::V3_2, "OpenAPI 3.2"),
] {
assert_eq!(v.label(), expected);
}
}
fn v2_spec() -> DetectedSpec {
let v: Value = serde_json::from_str(
r#"{"swagger":"2.0","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.unwrap();
detect_or_use(None, v).unwrap()
}
fn v3_0_spec() -> DetectedSpec {
let v: Value = serde_json::from_str(
r#"{"openapi":"3.0.4","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.unwrap();
detect_or_use(None, v).unwrap()
}
fn v3_1_spec() -> DetectedSpec {
let v: Value = serde_json::from_str(
r#"{"openapi":"3.1.0","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.unwrap();
detect_or_use(None, v).unwrap()
}
fn v3_2_spec() -> DetectedSpec {
let v: Value = serde_json::from_str(
r#"{"openapi":"3.2.0","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.unwrap();
detect_or_use(None, v).unwrap()
}
#[test]
fn detected_spec_version_and_label_cover_all_arms() {
assert_eq!(v2_spec().version(), SpecVersion::V2);
assert_eq!(v2_spec().label(), "OpenAPI 2.0");
assert_eq!(v3_0_spec().version(), SpecVersion::V3_0);
assert_eq!(v3_0_spec().label(), "OpenAPI 3.0");
assert_eq!(v3_1_spec().version(), SpecVersion::V3_1);
assert_eq!(v3_1_spec().label(), "OpenAPI 3.1");
assert_eq!(v3_2_spec().version(), SpecVersion::V3_2);
assert_eq!(v3_2_spec().label(), "OpenAPI 3.2");
}
#[test]
fn detected_spec_validate_dispatches_to_each_version() {
let opts = enumset::EnumSet::<Options>::new();
v2_spec().validate(opts, None).unwrap();
v3_0_spec().validate(opts, None).unwrap();
v3_1_spec().validate(opts, None).unwrap();
v3_2_spec().validate(opts, None).unwrap();
}
#[test]
fn convert_to_v2_same_version_noop_serialises_swagger() {
let out = v2_spec().convert_to(SpecVersion::V2).unwrap();
assert_eq!(out["swagger"], "2.0");
}
#[test]
fn convert_to_v3_0_same_version_noop() {
let out = v3_0_spec().convert_to(SpecVersion::V3_0).unwrap();
assert_major_minor(&out, "3.0");
}
#[test]
fn convert_to_v3_1_same_version_noop() {
let out = v3_1_spec().convert_to(SpecVersion::V3_1).unwrap();
assert_major_minor(&out, "3.1");
}
#[test]
fn convert_to_v2_to_v3_0_single_step() {
let out = v2_spec().convert_to(SpecVersion::V3_0).unwrap();
assert_major_minor(&out, "3.0");
}
#[test]
fn convert_to_v2_to_v3_1_chains_through_v3_0() {
let out = v2_spec().convert_to(SpecVersion::V3_1).unwrap();
assert_major_minor(&out, "3.1");
}
#[test]
fn convert_to_v3_0_to_v3_2_chains_through_v3_1() {
let out = v3_0_spec().convert_to(SpecVersion::V3_2).unwrap();
assert_major_minor(&out, "3.2");
}
#[test]
fn convert_to_v3_1_to_v3_2_single_step() {
let out = v3_1_spec().convert_to(SpecVersion::V3_2).unwrap();
assert_major_minor(&out, "3.2");
}
#[test]
fn convert_to_rejects_downconversion_safety_net() {
let err = v3_2_spec()
.convert_to(SpecVersion::V2)
.expect_err("downconversion must error")
.to_string();
assert!(err.contains("unsupported conversion"), "got: {err}",);
}
#[test]
fn parse_as_errors_when_forced_version_mismatches_doc() {
let v: Value = serde_json::from_str(
r#"{"openapi":"3.2.0","info":{"title":"x","version":"1"},"paths":{}}"#,
)
.unwrap();
let err = match detect_or_use(Some(SpecVersion::V2), v) {
Err(e) => e.to_string(),
Ok(_) => panic!("expected Err, got Ok"),
};
assert!(err.contains("deserialising as OpenAPI 2.0"), "got: {err}",);
}
#[test]
fn parse_version_returns_none_when_minor_is_not_parseable_int() {
assert_eq!(parse_version("3.."), None);
}
}