use crate::v3_1::spec::Spec as V31Spec;
use crate::v3_2::spec::Spec as V32Spec;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
impl From<V31Spec> for V32Spec {
fn from(v31: V31Spec) -> Self {
let mut value = serde_json::to_value(v31).expect("v3_1::Spec serializes");
transform_spec(&mut value);
serde_json::from_value(value).expect("transformed spec deserializes as v3_2::Spec")
}
}
fn transform_spec(spec: &mut Value) {
let Value::Object(obj) = spec else {
return;
};
obj.insert("openapi".into(), Value::String("3.2.0".to_owned()));
let tag_groups = obj.remove("x-tagGroups");
if let Some(Value::Array(tags)) = obj.get_mut("tags") {
for tag in tags.iter_mut() {
if let Value::Object(t) = tag {
migrate_x_display_name(t);
}
}
}
if let Some(Value::Array(groups)) = tag_groups {
apply_tag_groups(obj, groups);
}
}
fn migrate_x_display_name(tag: &mut Map<String, Value>) {
if let Some(v) = tag.remove("x-displayName") {
tag.entry("summary").or_insert(v);
}
}
fn apply_tag_groups(spec: &mut Map<String, Value>, groups: Vec<Value>) {
let had_tags = spec.contains_key("tags");
let mut tags: Vec<Value> = match spec.remove("tags") {
Some(Value::Array(a)) => a,
_ => Vec::new(),
};
let mut by_name: BTreeMap<String, usize> = BTreeMap::new();
for (i, tag) in tags.iter().enumerate() {
if let Some(name) = tag.get("name").and_then(|v| v.as_str()) {
by_name.entry(name.to_owned()).or_insert(i);
}
}
for group in groups {
let Value::Object(mut g) = group else {
continue;
};
let Some(group_name) = g.remove("name").and_then(string) else {
continue;
};
let members: Vec<String> = g
.remove("tags")
.and_then(|v| match v {
Value::Array(arr) => Some(arr),
_ => None,
})
.unwrap_or_default()
.into_iter()
.filter_map(string)
.collect();
let group_idx = ensure_tag(&mut tags, &mut by_name, &group_name);
if let Value::Object(t) = &mut tags[group_idx] {
t.entry("kind").or_insert(Value::String("nav".to_owned()));
for (k, v) in g {
t.entry(k).or_insert(v);
}
}
for member_name in members {
if member_name == group_name {
continue;
}
let idx = ensure_tag(&mut tags, &mut by_name, &member_name);
if let Value::Object(t) = &mut tags[idx] {
t.entry("parent")
.or_insert(Value::String(group_name.clone()));
}
}
}
if !tags.is_empty() || had_tags {
spec.insert("tags".into(), Value::Array(tags));
}
}
fn ensure_tag(tags: &mut Vec<Value>, by_name: &mut BTreeMap<String, usize>, name: &str) -> usize {
if let Some(&idx) = by_name.get(name) {
return idx;
}
let mut new_tag = Map::new();
new_tag.insert("name".into(), Value::String(name.to_owned()));
let idx = tags.len();
tags.push(Value::Object(new_tag));
by_name.insert(name.to_owned(), idx);
idx
}
fn string(v: Value) -> Option<String> {
match v {
Value::String(s) => Some(s),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::v3_1::spec::Spec as V31Spec;
use crate::v3_2::spec::Spec as V32Spec;
use crate::validation::{IGNORE_UNUSED, Options, Validate};
fn convert(raw: &str) -> Value {
let v31: V31Spec = serde_json::from_str(raw).expect("v3.1 spec parses");
let v32: V32Spec = v31.into();
serde_json::to_value(&v32).unwrap()
}
#[test]
fn openapi_version_lifted() {
let raw = r##"{
"openapi": "3.1.2",
"info": { "title": "t", "version": "1" },
"paths": {}
}"##;
let value = convert(raw);
assert_eq!(value["openapi"], "3.2.0");
}
#[test]
fn x_display_name_on_tag_becomes_summary() {
let raw = r##"{
"openapi": "3.1.2",
"info": { "title": "t", "version": "1" },
"paths": {},
"tags": [
{"name": "pets", "x-displayName": "Pets"}
]
}"##;
let value = convert(raw);
let tag = &value["tags"][0];
assert_eq!(tag["name"], "pets");
assert_eq!(tag["summary"], "Pets");
assert!(tag.get("x-displayName").is_none());
}
#[test]
fn x_display_name_does_not_clobber_existing_summary() {
let mut tag = serde_json::json!({
"name": "pets",
"summary": "kept",
"x-displayName": "dropped"
});
super::migrate_x_display_name(tag.as_object_mut().unwrap());
assert_eq!(tag["summary"], "kept");
assert!(tag.get("x-displayName").is_none());
}
#[test]
fn x_tag_groups_distributes_parents_and_synthesises_groups() {
let raw = r##"{
"openapi": "3.1.2",
"info": { "title": "t", "version": "1" },
"paths": {},
"tags": [
{"name": "books"},
{"name": "magazines"}
],
"x-tagGroups": [
{"name": "Products", "tags": ["books", "magazines"]}
]
}"##;
let value = convert(raw);
assert!(value.get("x-tagGroups").is_none(), "x-tagGroups dropped");
let tags = value["tags"].as_array().unwrap();
let by_name: BTreeMap<&str, &Value> = tags
.iter()
.map(|t| (t["name"].as_str().unwrap(), t))
.collect();
assert_eq!(by_name["books"]["parent"], "Products");
assert_eq!(by_name["magazines"]["parent"], "Products");
assert_eq!(by_name["Products"]["kind"], "nav");
}
#[test]
fn x_tag_groups_synthesises_missing_member_tags() {
let raw = r##"{
"openapi": "3.1.2",
"info": { "title": "t", "version": "1" },
"paths": {},
"x-tagGroups": [
{"name": "Core", "tags": ["pets"]}
]
}"##;
let value = convert(raw);
let tags = value["tags"].as_array().unwrap();
let by_name: BTreeMap<&str, &Value> = tags
.iter()
.map(|t| (t["name"].as_str().unwrap(), t))
.collect();
assert_eq!(by_name["pets"]["parent"], "Core");
assert_eq!(by_name["Core"]["kind"], "nav");
}
#[test]
fn x_tag_groups_extension_fields_migrate_onto_group_tag() {
let raw = r##"{
"openapi": "3.1.2",
"info": { "title": "t", "version": "1" },
"paths": {},
"x-tagGroups": [
{
"name": "Products",
"tags": ["books"],
"x-icon": "shopping-bag",
"x-order": 1
}
]
}"##;
let value = convert(raw);
let tags = value["tags"].as_array().unwrap();
let by_name: BTreeMap<&str, &Value> = tags
.iter()
.map(|t| (t["name"].as_str().unwrap(), t))
.collect();
let products = by_name["Products"];
assert_eq!(products["kind"], "nav");
assert_eq!(products["x-icon"], "shopping-bag");
assert_eq!(products["x-order"], 1);
}
#[test]
fn group_extension_does_not_clobber_existing_tag_extension() {
let mut spec = serde_json::json!({
"tags": [
{"name": "Products", "x-icon": "first"}
]
});
let groups = vec![serde_json::json!({
"name": "Products",
"tags": ["books"],
"x-icon": "second"
})];
super::apply_tag_groups(spec.as_object_mut().unwrap(), groups);
let tags = spec["tags"].as_array().unwrap();
let products = tags.iter().find(|t| t["name"] == "Products").unwrap();
assert_eq!(products["x-icon"], "first");
}
#[test]
fn group_member_matching_group_name_is_skipped() {
let mut spec = serde_json::json!({});
let groups = vec![serde_json::json!({
"name": "Products",
"tags": ["Products", "books"]
})];
super::apply_tag_groups(spec.as_object_mut().unwrap(), groups);
let tags = spec["tags"].as_array().unwrap();
let by_name: BTreeMap<&str, &Value> = tags
.iter()
.map(|t| (t["name"].as_str().unwrap(), t))
.collect();
assert_eq!(by_name["Products"]["kind"], "nav");
assert!(by_name["Products"].get("parent").is_none());
assert_eq!(by_name["books"]["parent"], "Products");
}
#[test]
fn duplicate_tag_names_resolve_to_first_declared() {
let mut spec = serde_json::json!({
"tags": [
{"name": "Products", "kind": "audience"},
{"name": "Products", "kind": "badge"}
]
});
let groups = vec![serde_json::json!({
"name": "Products",
"tags": []
})];
super::apply_tag_groups(spec.as_object_mut().unwrap(), groups);
let tags = spec["tags"].as_array().unwrap();
assert_eq!(tags[0]["kind"], "audience");
assert_eq!(tags[1]["kind"], "badge");
}
#[test]
fn empty_tags_array_is_preserved_when_x_tag_groups_is_empty() {
let mut spec = serde_json::json!({
"tags": []
});
let groups: Vec<Value> = Vec::new();
super::apply_tag_groups(spec.as_object_mut().unwrap(), groups);
assert_eq!(spec["tags"], serde_json::json!([]));
}
#[test]
fn x_tag_groups_preserves_existing_kind_on_group_tag() {
let mut spec = serde_json::json!({
"tags": [{"name": "Products", "kind": "audience"}]
});
let groups = vec![serde_json::json!({
"name": "Products",
"tags": ["books"]
})];
super::apply_tag_groups(spec.as_object_mut().unwrap(), groups);
let tags = spec["tags"].as_array().unwrap();
let products = tags.iter().find(|t| t["name"] == "Products").unwrap();
assert_eq!(products["kind"], "audience");
}
#[test]
fn x_tag_groups_keeps_existing_parent() {
let mut spec = serde_json::json!({
"tags": [{"name": "fiction", "parent": "Books"}]
});
let groups = vec![serde_json::json!({
"name": "Products",
"tags": ["fiction"]
})];
super::apply_tag_groups(spec.as_object_mut().unwrap(), groups);
let tags = spec["tags"].as_array().unwrap();
let fiction = tags.iter().find(|t| t["name"] == "fiction").unwrap();
assert_eq!(fiction["parent"], "Books");
}
#[test]
fn all_v3_1_fixtures_convert_to_valid_v3_2() {
let fixtures: &[(&str, &str)] = &[
(
"petstore",
include_str!("../../tests/v3_1_data/petstore.json"),
),
(
"petstore-expanded",
include_str!("../../tests/v3_1_data/petstore-expanded.json"),
),
(
"non-oauth-scopes",
include_str!("../../tests/v3_1_data/non-oauth-scopes.json"),
),
(
"tictactoe",
include_str!("../../tests/v3_1_data/tictactoe.json"),
),
(
"api-with-examples",
include_str!("../../tests/v3_1_data/api-with-examples.json"),
),
(
"callback-example",
include_str!("../../tests/v3_1_data/callback-example.json"),
),
(
"link-example",
include_str!("../../tests/v3_1_data/link-example.json"),
),
(
"oas-3-1-features",
include_str!("../../tests/v3_1_data/oas-3-1-features.json"),
),
("uspto", include_str!("../../tests/v3_1_data/uspto.json")),
];
let opts = Options::new() | Options::IgnoreMissingTags | IGNORE_UNUSED;
for (name, raw) in fixtures {
let v31: V31Spec =
serde_json::from_str(raw).unwrap_or_else(|e| panic!("{name}: parse: {e}"));
let v32: V32Spec = v31.into();
assert_eq!(v32.openapi.as_str(), "3.2.0", "{name} openapi version");
if let Err(e) = v32.validate(opts, None) {
panic!("{name}: converted spec did not validate cleanly:\n{e}");
}
}
}
#[test]
fn transform_spec_non_object_value_returns_early() {
let mut v = Value::String("not-an-object".into());
super::transform_spec(&mut v);
assert_eq!(v, Value::String("not-an-object".into()));
}
#[test]
fn apply_tag_groups_skips_non_object_group() {
let mut spec = serde_json::json!({});
let groups = vec![
Value::String("not-an-object".into()), serde_json::json!({"name": "Real", "tags": []}), ];
super::apply_tag_groups(spec.as_object_mut().unwrap(), groups);
let tags = spec["tags"].as_array().unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0]["name"], "Real");
}
#[test]
fn apply_tag_groups_skips_group_without_name() {
let mut spec = serde_json::json!({});
let groups = vec![
serde_json::json!({"tags": ["books"]}), ];
super::apply_tag_groups(spec.as_object_mut().unwrap(), groups);
assert!(
spec.get("tags").is_none() || spec["tags"].as_array().is_none_or(|a| a.is_empty()),
"expected no tags, got: {:?}",
spec.get("tags")
);
}
#[test]
fn apply_tag_groups_handles_non_array_tags_field() {
let mut spec = serde_json::json!({});
let groups = vec![serde_json::json!({"name": "Products", "tags": {"not": "an-array"}})];
super::apply_tag_groups(spec.as_object_mut().unwrap(), groups);
let tags = spec["tags"].as_array().unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0]["name"], "Products");
assert_eq!(tags[0]["kind"], "nav");
}
#[test]
fn string_helper_returns_none_for_non_string() {
assert_eq!(super::string(Value::Bool(true)), None);
assert_eq!(super::string(Value::Null), None);
assert_eq!(super::string(Value::Number(42.into())), None);
assert_eq!(
super::string(Value::String("hello".into())),
Some("hello".to_owned())
);
}
}