use crate::v3_0::spec::Spec as V30Spec;
use crate::v3_1::spec::Spec as V31Spec;
use serde_json::{Map, Value};
impl From<V30Spec> for V31Spec {
fn from(v30: V30Spec) -> Self {
let mut value = serde_json::to_value(v30).expect("v3_0::Spec serializes");
transform_spec(&mut value);
serde_json::from_value(value).expect("transformed spec deserializes as v3_1::Spec")
}
}
fn transform_spec(spec: &mut Value) {
let Value::Object(obj) = spec else {
return;
};
obj.insert("openapi".into(), Value::String("3.1.2".to_owned()));
walk_content_aware(spec);
transform_schemas_recursive(spec);
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Pos {
Generic,
Schema,
SchemaMap,
Link,
LinkMap,
}
fn transform_schemas_recursive(value: &mut Value) {
walk(value, Pos::Generic);
}
fn walk(value: &mut Value, pos: Pos) {
match value {
Value::Object(obj) => match pos {
Pos::Schema => walk_schema_object(obj),
Pos::SchemaMap => {
for (_, v) in obj.iter_mut() {
walk(v, Pos::Schema);
}
}
Pos::Link => walk_link_object(obj),
Pos::LinkMap => {
for (_, v) in obj.iter_mut() {
walk(v, Pos::Link);
}
}
Pos::Generic => walk_generic_object(obj),
},
Value::Array(arr) => {
for v in arr.iter_mut() {
walk(v, pos);
}
}
_ => {}
}
}
fn walk_link_object(obj: &mut Map<String, Value>) {
for (k, v) in obj.iter_mut() {
if is_extension_key(k) {
continue;
}
match k.as_str() {
"parameters" | "requestBody" => continue,
_ => walk(v, Pos::Generic),
}
}
}
fn walk_schema_object(obj: &mut Map<String, Value>) {
normalize_nullable(obj);
normalize_exclusive_bound(obj, "exclusiveMinimum", "minimum");
normalize_exclusive_bound(obj, "exclusiveMaximum", "maximum");
normalize_example_to_examples(obj);
normalize_base64_format(obj);
for (k, v) in obj.iter_mut() {
if is_extension_key(k) {
continue;
}
match k.as_str() {
"example" | "examples" | "default" | "enum" | "const" => continue,
"items"
| "not"
| "additionalProperties"
| "contains"
| "propertyNames"
| "if"
| "then"
| "else"
| "unevaluatedItems"
| "unevaluatedProperties" => walk(v, Pos::Schema),
"allOf" | "anyOf" | "oneOf" | "prefixItems" => walk(v, Pos::Schema),
"properties" | "patternProperties" | "$defs" | "definitions" | "dependentSchemas" => {
walk(v, Pos::SchemaMap)
}
_ => walk(v, Pos::Generic),
}
}
}
fn walk_generic_object(obj: &mut Map<String, Value>) {
for (k, v) in obj.iter_mut() {
if is_extension_key(k) {
continue;
}
match k.as_str() {
"schema" => walk(v, Pos::Schema),
"schemas" => walk(v, Pos::SchemaMap),
"links" => walk(v, Pos::LinkMap),
"example" | "examples" | "value" => continue,
_ => walk(v, Pos::Generic),
}
}
}
fn is_extension_key(k: &str) -> bool {
k.starts_with("x-")
}
fn normalize_nullable(obj: &mut Map<String, Value>) {
let nullable = matches!(obj.remove("nullable"), Some(Value::Bool(true)));
if !nullable {
return;
}
match obj.remove("type") {
Some(Value::String(t)) if t != "null" => {
obj.insert(
"type".into(),
Value::Array(vec![Value::String(t), Value::String("null".into())]),
);
}
Some(Value::Array(mut arr)) => {
if !arr.iter().any(|v| v.as_str() == Some("null")) {
arr.push(Value::String("null".into()));
}
obj.insert("type".into(), Value::Array(arr));
}
Some(other) => {
obj.insert("type".into(), other);
}
None => {
}
}
}
fn normalize_exclusive_bound(
obj: &mut Map<String, Value>,
exclusive_key: &str,
inclusive_key: &str,
) {
match obj.get(exclusive_key) {
Some(Value::Bool(true)) => {
obj.remove(exclusive_key);
if let Some(bound) = obj.remove(inclusive_key) {
obj.insert(exclusive_key.to_owned(), bound);
}
}
Some(Value::Bool(false)) => {
obj.remove(exclusive_key);
}
_ => {}
}
}
fn normalize_example_to_examples(obj: &mut Map<String, Value>) {
let Some(example) = obj.remove("example") else {
return;
};
if obj.contains_key("examples") {
return;
}
obj.insert("examples".into(), Value::Array(vec![example]));
}
fn normalize_base64_format(obj: &mut Map<String, Value>) {
if !type_includes_string(obj) {
return;
}
if obj.get("format").and_then(|v| v.as_str()) != Some("base64") {
return;
}
obj.remove("format");
obj.insert("contentEncoding".into(), Value::String("base64".into()));
}
fn type_includes_string(obj: &Map<String, Value>) -> bool {
match obj.get("type") {
Some(Value::String(s)) => s == "string",
Some(Value::Array(arr)) => arr.iter().any(|v| v.as_str() == Some("string")),
_ => false,
}
}
fn walk_content_aware(value: &mut Value) {
walk_content_aware_with(value, false);
}
fn walk_content_aware_with(value: &mut Value, in_link: bool) {
match value {
Value::Object(obj) => {
if !in_link && let Some(Value::Object(content)) = obj.get_mut("content") {
rewrite_content_map(content);
}
for (k, v) in obj.iter_mut() {
if is_extension_key(k) {
continue;
}
if in_link {
if matches!(k.as_str(), "parameters" | "requestBody") {
continue;
}
walk_content_aware_with(v, false);
continue;
}
if matches!(
k.as_str(),
"example" | "examples" | "default" | "enum" | "const" | "value"
) {
continue;
}
if k == "links" {
if let Value::Object(map) = v {
for (_, entry) in map.iter_mut() {
walk_content_aware_with(entry, true);
}
}
continue;
}
walk_content_aware_with(v, false);
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
walk_content_aware_with(v, in_link);
}
}
_ => {}
}
}
fn rewrite_content_map(content: &mut Map<String, Value>) {
for (mime, media_type) in content.iter_mut() {
let Value::Object(media) = media_type else {
continue;
};
let Some(schema) = media.get_mut("schema") else {
continue;
};
let mime_main = mime_main_type(mime);
if mime_main.eq_ignore_ascii_case("application/octet-stream") {
if let Value::Object(s) = schema
&& is_string_binary(s)
&& s.len() == 2
{
*schema = Value::Object(Map::new());
}
} else if is_multipart_mime(mime_main)
&& let Value::Object(s) = schema
{
rewrite_string_binary_subschemas(s);
}
}
}
fn mime_main_type(mime: &str) -> &str {
mime.split(';').next().unwrap_or(mime).trim()
}
fn is_multipart_mime(mime: &str) -> bool {
let prefix = "multipart/";
mime.get(..prefix.len())
.is_some_and(|h| h.eq_ignore_ascii_case(prefix))
}
fn is_string_binary(schema: &Map<String, Value>) -> bool {
schema.get("type").and_then(|v| v.as_str()) == Some("string")
&& schema.get("format").and_then(|v| v.as_str()) == Some("binary")
}
fn rewrite_string_binary_subschemas(schema: &mut Map<String, Value>) {
if is_string_binary(schema) {
schema.remove("format");
schema.insert(
"contentMediaType".into(),
Value::String("application/octet-stream".into()),
);
return;
}
for (k, v) in schema.iter_mut() {
match k.as_str() {
"example" | "examples" | "default" | "enum" | "const" => continue,
"items"
| "not"
| "additionalProperties"
| "contains"
| "propertyNames"
| "if"
| "then"
| "else"
| "unevaluatedItems"
| "unevaluatedProperties" => {
if let Value::Object(s) = v {
rewrite_string_binary_subschemas(s);
}
}
"allOf" | "anyOf" | "oneOf" | "prefixItems" => {
if let Value::Array(arr) = v {
for entry in arr.iter_mut() {
if let Value::Object(s) = entry {
rewrite_string_binary_subschemas(s);
}
}
}
}
"properties" | "patternProperties" | "$defs" | "definitions" | "dependentSchemas" => {
if let Value::Object(map) = v {
for (_, entry) in map.iter_mut() {
if let Value::Object(s) = entry {
rewrite_string_binary_subschemas(s);
}
}
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::v3_0::spec::Spec as V30Spec;
use crate::v3_1::spec::Spec as V31Spec;
use crate::validation::{IGNORE_UNUSED, Options, Validate};
fn v30_from_json(s: &str) -> V30Spec {
serde_json::from_str(s).expect("v3.0 spec parses")
}
fn convert(raw: &str) -> Value {
let v30: V30Spec = v30_from_json(raw);
let v31: V31Spec = v30.into();
serde_json::to_value(&v31).unwrap()
}
#[test]
fn openapi_version_lifted() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {}
}"##;
let value = convert(raw);
assert_eq!(value["openapi"], "3.1.2");
}
#[test]
fn nullable_promotes_type_into_array() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"MaybeName": {"type": "string", "nullable": true},
"MaybeBool": {"type": "boolean"}
}
}
}"##;
let value = convert(raw);
let maybe_name = &value["components"]["schemas"]["MaybeName"];
assert_eq!(maybe_name["type"], serde_json::json!(["string", "null"]));
assert!(
maybe_name.get("nullable").is_none(),
"nullable keyword removed"
);
let maybe_bool = &value["components"]["schemas"]["MaybeBool"];
assert_eq!(maybe_bool["type"], "boolean");
}
#[test]
fn nullable_without_type_stays_typeless() {
let mut v: Value = serde_json::json!({"nullable": true, "description": "free-form"});
super::walk(&mut v, super::Pos::Schema);
let free = &v;
assert!(
free.get("type").is_none(),
"no `type` should be synthesised, got {free}"
);
assert!(free.get("nullable").is_none(), "nullable removed");
assert_eq!(free["description"], "free-form");
}
#[test]
fn nullable_string_with_constraints_round_trips_via_extensions() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Slug": {
"type": "string",
"nullable": true,
"minLength": 3,
"maxLength": 32,
"pattern": "^[a-z][a-z0-9-]*$",
"enum": ["alpha", "beta"]
}
}
}
}"##;
let value = convert(raw);
let slug = &value["components"]["schemas"]["Slug"];
assert_eq!(slug["type"], serde_json::json!(["string", "null"]));
assert_eq!(slug["minLength"], 3);
assert_eq!(slug["maxLength"], 32);
assert_eq!(slug["pattern"], "^[a-z][a-z0-9-]*$");
assert_eq!(slug["enum"], serde_json::json!(["alpha", "beta"]));
}
#[test]
fn nullable_object_with_properties_round_trips_via_extensions() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Pet": {
"type": "object",
"nullable": true,
"required": ["id"],
"properties": {
"id": {"type": "integer"},
"name": {"type": "string", "nullable": true}
}
}
}
}
}"##;
let value = convert(raw);
let pet = &value["components"]["schemas"]["Pet"];
assert_eq!(pet["type"], serde_json::json!(["object", "null"]));
assert_eq!(pet["required"], serde_json::json!(["id"]));
assert_eq!(pet["properties"]["id"]["type"], "integer");
assert_eq!(
pet["properties"]["name"]["type"],
serde_json::json!(["string", "null"])
);
}
#[test]
fn nullable_array_with_items_round_trips_via_extensions() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Tags": {
"type": "array",
"nullable": true,
"minItems": 1,
"uniqueItems": true,
"items": {"type": "string"}
}
}
}
}"##;
let value = convert(raw);
let tags = &value["components"]["schemas"]["Tags"];
assert_eq!(tags["type"], serde_json::json!(["array", "null"]));
assert_eq!(tags["minItems"], 1);
assert_eq!(tags["uniqueItems"], true);
assert_eq!(tags["items"]["type"], "string");
}
#[test]
fn exclusive_bound_collapses_to_number() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Pos": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 100,
"exclusiveMaximum": true
}
}
}
}"##;
let value = convert(raw);
let pos = &value["components"]["schemas"]["Pos"];
assert_eq!(pos["exclusiveMinimum"], 0);
assert_eq!(pos["exclusiveMaximum"], 100);
assert!(pos.get("minimum").is_none(), "redundant minimum dropped");
assert!(pos.get("maximum").is_none(), "redundant maximum dropped");
}
#[test]
fn exclusive_bound_false_is_just_removed() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"InclOnly": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": false
}
}
}
}"##;
let value = convert(raw);
let s = &value["components"]["schemas"]["InclOnly"];
assert_eq!(s["minimum"], 0);
assert!(s.get("exclusiveMinimum").is_none());
}
#[test]
fn schema_example_becomes_examples_array() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Pet": {"type": "string", "example": "fedora"}
}
}
}"##;
let value = convert(raw);
let pet = &value["components"]["schemas"]["Pet"];
assert_eq!(pet["examples"], serde_json::json!(["fedora"]));
assert!(pet.get("example").is_none());
}
#[test]
fn schema_example_payload_is_preserved_byte_for_byte() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Cfg": {
"type": "object",
"example": {
"nullable": true,
"type": "string",
"exclusiveMinimum": true,
"minimum": 0,
"format": "base64"
}
}
}
}
}"##;
let value = convert(raw);
let payload = &value["components"]["schemas"]["Cfg"]["examples"][0];
assert_eq!(payload["nullable"], true);
assert_eq!(payload["type"], "string");
assert_eq!(payload["exclusiveMinimum"], true);
assert_eq!(payload["minimum"], 0);
assert_eq!(payload["format"], "base64");
}
#[test]
fn schema_default_payload_is_preserved_byte_for_byte() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Cfg": {
"type": "object",
"default": {"type": "string", "nullable": true}
}
}
}
}"##;
let value = convert(raw);
let default = &value["components"]["schemas"]["Cfg"]["default"];
assert_eq!(default["type"], "string");
assert_eq!(default["nullable"], true);
}
#[test]
fn property_named_example_is_walked_as_a_subschema() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Cfg": {
"type": "object",
"properties": {
"example": {"type": "string", "nullable": true},
"default": {"type": "integer", "nullable": true}
}
}
}
}
}"##;
let value = convert(raw);
let props = &value["components"]["schemas"]["Cfg"]["properties"];
assert_eq!(
props["example"]["type"],
serde_json::json!(["string", "null"])
);
assert_eq!(
props["default"]["type"],
serde_json::json!(["integer", "null"])
);
}
#[test]
fn media_type_examples_payload_is_preserved_byte_for_byte() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {"type": "object"},
"examples": {
"trap": {
"value": {
"type": "string",
"nullable": true,
"format": "base64"
}
}
}
}
}
}
}
}
}
}
}"##;
let value = convert(raw);
let trap = &value["paths"]["/x"]["get"]["responses"]["200"]["content"]["application/json"]
["examples"]["trap"]["value"];
assert_eq!(trap["type"], "string");
assert_eq!(trap["nullable"], true);
assert_eq!(trap["format"], "base64");
}
#[test]
fn parameter_example_is_kept_as_is() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/items": {
"get": {
"parameters": [{
"in": "query",
"name": "limit",
"schema": {"type": "integer"},
"example": 10
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let p = &value["paths"]["/items"]["get"]["parameters"][0];
assert_eq!(p["example"], 10);
}
#[test]
fn octet_stream_binary_schema_becomes_empty_schema() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"requestBody": {
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
}
}
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let schema = &value["paths"]["/upload"]["post"]["requestBody"]["content"]["application/octet-stream"]
["schema"];
assert!(schema.is_object());
assert!(
schema.as_object().unwrap().is_empty(),
"octet-stream schema should be `{{}}`, got {schema}"
);
}
#[test]
fn octet_stream_with_non_binary_schema_is_kept() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"requestBody": {
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "base64"}
}
}
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let schema = &value["paths"]["/upload"]["post"]["requestBody"]["content"]["application/octet-stream"]
["schema"];
assert_eq!(schema["type"], "string");
assert_eq!(schema["contentEncoding"], "base64");
}
#[test]
fn multipart_binary_property_uses_content_media_type() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"file": {"type": "string", "format": "binary"},
"name": {"type": "string"}
}
}
}
}
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let props = &value["paths"]["/upload"]["post"]["requestBody"]["content"]["multipart/form-data"]
["schema"]["properties"];
let file = &props["file"];
assert_eq!(file["type"], "string");
assert_eq!(file["contentMediaType"], "application/octet-stream");
assert!(file.get("format").is_none(), "format dropped");
assert_eq!(props["name"]["type"], "string");
}
#[test]
fn multipart_binary_array_items_uses_content_media_type() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"files": {
"type": "array",
"items": {"type": "string", "format": "binary"}
}
}
}
}
}
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let items = &value["paths"]["/upload"]["post"]["requestBody"]["content"]["multipart/form-data"]
["schema"]["properties"]["files"]["items"];
assert_eq!(items["type"], "string");
assert_eq!(items["contentMediaType"], "application/octet-stream");
assert!(items.get("format").is_none(), "format dropped on items too");
}
#[test]
fn multipart_nested_binary_property_uses_content_media_type() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"envelope": {
"type": "object",
"properties": {
"blob": {"type": "string", "format": "binary"}
}
}
}
}
}
}
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let blob = &value["paths"]["/upload"]["post"]["requestBody"]["content"]["multipart/form-data"]
["schema"]["properties"]["envelope"]["properties"]["blob"];
assert_eq!(blob["contentMediaType"], "application/octet-stream");
assert!(blob.get("format").is_none());
}
#[test]
fn content_aware_walk_skips_example_payload() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Doc": {
"type": "object",
"example": {
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"f": {"type": "string", "format": "binary"}
}
}
}
}
}
}
}
}
}"##;
let value = convert(raw);
let payload = &value["components"]["schemas"]["Doc"]["examples"][0];
let octet_schema = &payload["content"]["application/octet-stream"]["schema"];
assert_eq!(octet_schema["type"], "string");
assert_eq!(octet_schema["format"], "binary");
let multipart_field =
&payload["content"]["multipart/form-data"]["schema"]["properties"]["f"];
assert_eq!(multipart_field["type"], "string");
assert_eq!(multipart_field["format"], "binary");
assert!(
multipart_field.get("contentMediaType").is_none(),
"example payload must not gain contentMediaType"
);
}
#[test]
fn non_ascii_media_type_key_does_not_panic() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"post": {
"requestBody": {
"content": {
"🦀/binary": {
"schema": {"type": "string", "format": "binary"}
}
}
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let schema = &value["paths"]["/x"]["post"]["requestBody"]["content"]["🦀/binary"]["schema"];
assert_eq!(schema["type"], "string");
assert_eq!(schema["format"], "binary");
}
#[test]
fn media_type_match_is_case_insensitive() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"requestBody": {
"content": {
"Application/Octet-Stream": {
"schema": {"type": "string", "format": "binary"}
},
"Multipart/Form-Data": {
"schema": {
"type": "object",
"properties": {
"f": {"type": "string", "format": "binary"}
}
}
}
}
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let octet = &value["paths"]["/upload"]["post"]["requestBody"]["content"]["Application/Octet-Stream"]
["schema"];
assert!(octet.is_object());
assert!(octet.as_object().unwrap().is_empty());
let f = &value["paths"]["/upload"]["post"]["requestBody"]["content"]["Multipart/Form-Data"]
["schema"]["properties"]["f"];
assert_eq!(f["type"], "string");
assert_eq!(f["contentMediaType"], "application/octet-stream");
assert!(f.get("format").is_none());
}
#[test]
fn octet_stream_with_media_type_parameters_is_rewritten() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"requestBody": {
"content": {
"application/octet-stream; charset=binary": {
"schema": {"type": "string", "format": "binary"}
}
}
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let schema = &value["paths"]["/upload"]["post"]["requestBody"]["content"]["application/octet-stream; charset=binary"]
["schema"];
assert!(schema.is_object());
assert!(schema.as_object().unwrap().is_empty());
}
#[test]
fn multipart_with_media_type_parameters_rewrites_binary_props() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"requestBody": {
"content": {
"multipart/form-data; boundary=ABCD": {
"schema": {
"type": "object",
"properties": {
"file": {"type": "string", "format": "binary"}
}
}
}
}
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let file = &value["paths"]["/upload"]["post"]["requestBody"]["content"]["multipart/form-data; boundary=ABCD"]
["schema"]["properties"]["file"];
assert_eq!(file["type"], "string");
assert_eq!(file["contentMediaType"], "application/octet-stream");
assert!(file.get("format").is_none());
}
#[test]
fn octet_stream_binary_with_extra_fields_is_preserved() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"requestBody": {
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary",
"description": "the document bytes"
}
}
}
},
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let value = convert(raw);
let schema = &value["paths"]["/upload"]["post"]["requestBody"]["content"]["application/octet-stream"]
["schema"];
assert_eq!(schema["type"], "string");
assert_eq!(schema["format"], "binary");
assert_eq!(schema["description"], "the document bytes");
}
#[test]
fn link_parameters_and_request_body_are_opaque_to_walkers() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"operationId": "getX",
"responses": {
"200": {
"description": "ok",
"links": {
"trap": {
"operationId": "getX",
"parameters": {
"p": {
"schema": {
"type": "string",
"nullable": true,
"format": "base64"
}
}
},
"requestBody": {
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
}
}
}
}
}
}
}
}
}
}
}"##;
let value = convert(raw);
let link = &value["paths"]["/x"]["get"]["responses"]["200"]["links"]["trap"];
let p_schema = &link["parameters"]["p"]["schema"];
assert_eq!(p_schema["type"], "string");
assert_eq!(p_schema["nullable"], true);
assert_eq!(p_schema["format"], "base64");
assert!(p_schema.get("contentEncoding").is_none());
let body_schema = &link["requestBody"]["content"]["application/octet-stream"]["schema"];
assert_eq!(body_schema["type"], "string");
assert_eq!(body_schema["format"], "binary");
}
#[test]
fn x_extension_payloads_are_opaque_to_walkers() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Cfg": {
"type": "object",
"x-json-schema": {
"type": "string",
"nullable": true,
"format": "base64",
"example": "abc"
},
"x-vendor-content": {
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
},
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"f": {"type": "string", "format": "binary"}
}
}
}
}
}
}
}
}
}"##;
let value = convert(raw);
let cfg = &value["components"]["schemas"]["Cfg"];
let xjs = &cfg["x-json-schema"];
assert_eq!(xjs["type"], "string");
assert_eq!(xjs["nullable"], true);
assert_eq!(xjs["format"], "base64");
assert_eq!(xjs["example"], "abc");
assert!(xjs.get("contentEncoding").is_none());
assert!(xjs.get("examples").is_none());
let xvc = &cfg["x-vendor-content"];
let octet = &xvc["content"]["application/octet-stream"]["schema"];
assert_eq!(octet["type"], "string");
assert_eq!(octet["format"], "binary");
let multipart_field = &xvc["content"]["multipart/form-data"]["schema"]["properties"]["f"];
assert_eq!(multipart_field["type"], "string");
assert_eq!(multipart_field["format"], "binary");
assert!(multipart_field.get("contentMediaType").is_none());
}
#[test]
fn nullable_string_with_base64_format_gets_content_encoding() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Token": {
"type": "string",
"format": "base64",
"nullable": true
}
}
}
}"##;
let value = convert(raw);
let token = &value["components"]["schemas"]["Token"];
assert_eq!(token["type"], serde_json::json!(["string", "null"]));
assert_eq!(token["contentEncoding"], "base64");
assert!(token.get("format").is_none());
}
#[test]
fn base64_format_becomes_content_encoding() {
let raw = r##"{
"openapi": "3.0.4",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Token": {"type": "string", "format": "base64"}
}
}
}"##;
let value = convert(raw);
let token = &value["components"]["schemas"]["Token"];
assert_eq!(token["type"], "string");
assert_eq!(token["contentEncoding"], "base64");
assert!(token.get("format").is_none());
}
#[test]
fn all_v3_0_fixtures_convert_to_valid_v3_1() {
let fixtures: &[(&str, &str)] = &[
(
"petstore",
include_str!("../../tests/v3_0_data/petstore.json"),
),
(
"petstore-expanded",
include_str!("../../tests/v3_0_data/petstore-expanded.json"),
),
(
"api-with-examples",
include_str!("../../tests/v3_0_data/api-with-examples.json"),
),
(
"callback-example",
include_str!("../../tests/v3_0_data/callback-example.json"),
),
(
"link-example",
include_str!("../../tests/v3_0_data/link-example.json"),
),
];
let opts = Options::new() | Options::IgnoreMissingTags | IGNORE_UNUSED;
for (name, raw) in fixtures {
let v30: V30Spec =
serde_json::from_str(raw).unwrap_or_else(|e| panic!("{name}: parse: {e}"));
let v31: V31Spec = v30.into();
assert_eq!(v31.openapi.as_str(), "3.1.2", "{name} openapi version");
if let Err(e) = v31.validate(opts) {
panic!("{name}: converted spec did not validate cleanly:\n{e}");
}
}
}
}