use crate::v2::spec::Spec as V2Spec;
use crate::v3_0::spec::Spec as V3Spec;
use serde_json::{Map, Value};
use std::collections::HashSet;
impl From<V2Spec> for V3Spec {
fn from(v2: V2Spec) -> Self {
let mut value = serde_json::to_value(v2).expect("v2::Spec serializes");
transform_spec(&mut value);
serde_json::from_value(value).expect("transformed spec deserializes as v3_0::Spec")
}
}
fn transform_spec(spec: &mut Value) {
let Value::Object(obj) = spec else {
return;
};
obj.remove("swagger");
obj.insert("openapi".into(), Value::String("3.0.4".to_owned()));
let host = obj.remove("host").and_then(string);
let base_path = obj.remove("basePath").and_then(string);
let spec_schemes = obj
.remove("schemes")
.and_then(|v| v.as_array().cloned())
.unwrap_or_default();
let spec_schemes_str: Vec<String> = spec_schemes
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect();
let x_servers = obj.remove("x-servers");
let promoted_x_servers = match x_servers {
Some(Value::Array(arr)) if !arr.is_empty() => {
obj.insert("servers".into(), Value::Array(arr));
true
}
_ => false,
};
if !promoted_x_servers
&& let Some(servers) =
assemble_servers(host.as_deref(), base_path.as_deref(), &spec_schemes_str)
{
obj.insert("servers".into(), servers);
}
let spec_consumes = take_string_array(obj, "consumes");
let spec_produces = take_string_array(obj, "produces");
let mut body_param_names: HashSet<String> = HashSet::new();
let mut form_param_names: HashSet<String> = HashSet::new();
let mut form_param_defs: Map<String, Value> = Map::new();
let mut converted_parameters: Map<String, Value> = Map::new();
let mut request_bodies: Map<String, Value> = Map::new();
if let Some(Value::Object(parameters)) = obj.remove("parameters") {
for (name, value) in parameters {
match parameter_location(&value) {
Some("body") => {
body_param_names.insert(name.clone());
if let Some(rb) = build_body_request_body(Some(value), &spec_consumes) {
request_bodies.insert(name, rb);
}
}
Some("formData") => {
form_param_names.insert(name.clone());
form_param_defs.insert(name.clone(), value.clone());
if let Some(rb) = build_form_request_body(vec![value], &spec_consumes) {
request_bodies.insert(name, rb);
}
}
_ => {
if let Some(p) = transform_non_body_parameter(value) {
converted_parameters.insert(name, p);
}
}
}
}
}
let definitions = obj.remove("definitions");
let responses = obj.remove("responses");
let security_definitions = obj.remove("securityDefinitions");
let path_ctx = PathCtx {
spec_consumes: &spec_consumes,
spec_produces: &spec_produces,
body_param_names: &body_param_names,
form_param_names: &form_param_names,
form_param_defs: &form_param_defs,
host: host.as_deref(),
base_path: base_path.as_deref(),
};
if let Some(paths) = obj.get_mut("paths") {
transform_paths(paths, &path_ctx);
}
let has_components = definitions.is_some()
|| !converted_parameters.is_empty()
|| responses.is_some()
|| security_definitions.is_some()
|| !request_bodies.is_empty();
if has_components {
let mut components = Map::new();
if let Some(d) = definitions {
components.insert("schemas".into(), d);
}
if !converted_parameters.is_empty() {
components.insert("parameters".into(), Value::Object(converted_parameters));
}
if let Some(mut r) = responses {
if let Value::Object(map) = &mut r {
for (_, resp) in map.iter_mut() {
transform_response(resp, &spec_produces);
}
}
components.insert("responses".into(), r);
}
if !request_bodies.is_empty() {
components.insert("requestBodies".into(), Value::Object(request_bodies));
}
if let Some(mut sd) = security_definitions {
transform_security_definitions(&mut sd);
components.insert("securitySchemes".into(), sd);
}
obj.insert("components".into(), Value::Object(components));
}
walk(spec, &body_param_names, &form_param_names, Pos::Generic);
}
struct PathCtx<'a> {
spec_consumes: &'a [String],
spec_produces: &'a [String],
body_param_names: &'a HashSet<String>,
form_param_names: &'a HashSet<String>,
form_param_defs: &'a Map<String, Value>,
host: Option<&'a str>,
base_path: Option<&'a str>,
}
fn assemble_servers(
host: Option<&str>,
base_path: Option<&str>,
schemes: &[String],
) -> Option<Value> {
let host = host.unwrap_or("").trim();
let base = base_path.unwrap_or("").trim();
if host.is_empty() && base.is_empty() {
return None;
}
let default_schemes;
let schemes: &[String] = if schemes.is_empty() {
default_schemes = vec!["https".to_owned()];
&default_schemes
} else {
schemes
};
let mut out = Vec::with_capacity(schemes.len());
for scheme in schemes {
let url = if host.is_empty() {
base.to_owned()
} else {
format!("{scheme}://{host}{base}")
};
let mut entry = Map::new();
entry.insert("url".into(), Value::String(url));
out.push(Value::Object(entry));
}
out.dedup();
Some(Value::Array(out))
}
fn transform_paths(paths: &mut Value, ctx: &PathCtx<'_>) {
let Value::Object(obj) = paths else { return };
for (name, item) in obj.iter_mut() {
if name.starts_with("x-") {
continue;
}
let Value::Object(item_obj) = item else {
continue;
};
let mut path_body: Option<Value> = None;
let mut path_forms: Vec<Value> = Vec::new();
if let Some(Value::Array(parameters)) = item_obj.remove("parameters") {
let mut new_path_params: Vec<Value> = Vec::with_capacity(parameters.len());
for p in parameters {
match classify_parameter(&p, ctx.body_param_names, ctx.form_param_names) {
ParamKind::Body => {
path_body = Some(p);
}
ParamKind::Form => {
path_forms.push(p);
}
ParamKind::Other => {
if let Some(rewritten) = transform_non_body_parameter(p) {
new_path_params.push(rewritten);
}
}
}
}
if !new_path_params.is_empty() {
item_obj.insert("parameters".into(), Value::Array(new_path_params));
}
}
for (method, op) in item_obj.iter_mut() {
if !is_http_method(method) {
continue;
}
transform_operation(op, ctx, path_body.as_ref(), &path_forms);
}
}
}
enum ParamKind {
Body,
Form,
Other,
}
fn classify_parameter(
p: &Value,
body_param_names: &HashSet<String>,
form_param_names: &HashSet<String>,
) -> ParamKind {
match parameter_location(p) {
Some("body") => return ParamKind::Body,
Some("formData") => return ParamKind::Form,
Some(_) => return ParamKind::Other,
None => {}
}
if let Some(name) = ref_local_name(p, "#/parameters/") {
if body_param_names.contains(name) {
return ParamKind::Body;
}
if form_param_names.contains(name) {
return ParamKind::Form;
}
}
ParamKind::Other
}
fn ref_local_name<'a>(p: &'a Value, prefix: &str) -> Option<&'a str> {
p.get("$ref")?.as_str()?.strip_prefix(prefix)
}
fn is_http_method(name: &str) -> bool {
matches!(
name,
"get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace"
)
}
fn transform_operation(
op: &mut Value,
ctx: &PathCtx<'_>,
path_body: Option<&Value>,
path_forms: &[Value],
) {
let Value::Object(obj) = op else { return };
let consumes = take_string_array(obj, "consumes");
let consumes: Vec<String> = if consumes.is_empty() {
ctx.spec_consumes.to_vec()
} else {
consumes
};
let produces = take_string_array(obj, "produces");
let produces: Vec<String> = if produces.is_empty() {
ctx.spec_produces.to_vec()
} else {
produces
};
let op_schemes = take_string_array(obj, "schemes");
if !op_schemes.is_empty()
&& let Some(servers) = assemble_servers(ctx.host, ctx.base_path, &op_schemes)
{
obj.insert("servers".into(), servers);
}
let mut body_param: Option<Value> = None;
let mut form_params: Vec<Value> = Vec::new();
let mut other_params: Vec<Value> = Vec::new();
if let Some(Value::Array(parameters)) = obj.remove("parameters") {
for p in parameters {
match classify_parameter(&p, ctx.body_param_names, ctx.form_param_names) {
ParamKind::Body => body_param = Some(p),
ParamKind::Form => form_params.push(p),
ParamKind::Other => other_params.push(p),
}
}
}
if body_param.is_none()
&& let Some(pb) = path_body
{
body_param = Some(pb.clone());
}
if !path_forms.is_empty() {
form_params = merge_form_params(path_forms, form_params);
}
let mut new_params = Vec::with_capacity(other_params.len());
for p in other_params {
if let Some(rewritten) = transform_non_body_parameter(p) {
new_params.push(rewritten);
}
}
if !new_params.is_empty() {
obj.insert("parameters".into(), Value::Array(new_params));
}
if let Some(body) = body_param {
if body.as_object().is_some_and(|o| o.contains_key("$ref")) {
obj.insert("requestBody".into(), body);
} else if let Some(rb) = build_body_request_body(Some(body), &consumes) {
obj.insert("requestBody".into(), rb);
}
} else if !form_params.is_empty()
&& let Some(rb) =
build_form_request_body_or_ref(form_params, &consumes, ctx.form_param_defs)
{
obj.insert("requestBody".into(), rb);
}
if let Some(responses) = obj.get_mut("responses")
&& let Value::Object(resp_obj) = responses
{
for (_, resp) in resp_obj.iter_mut() {
transform_response(resp, &produces);
}
}
}
fn parameter_location(p: &Value) -> Option<&str> {
p.get("in")?.as_str()
}
fn transform_non_body_parameter(mut p: Value) -> Option<Value> {
if p.is_object() && p.as_object().is_some_and(|o| o.contains_key("$ref")) {
return Some(p);
}
let Value::Object(obj) = &mut p else {
return Some(p);
};
let location = obj.get("in").and_then(|v| v.as_str()).map(str::to_owned);
let collection_format = obj.remove("collectionFormat").and_then(string);
if let Some((style, explode)) = collection_format
.as_deref()
.zip(location.as_deref())
.and_then(|(cf, loc)| collection_format_to_style(cf, loc))
{
obj.insert("style".into(), Value::String(style.into()));
obj.insert("explode".into(), Value::Bool(explode));
}
if location.as_deref() != Some("query") {
obj.remove("allowEmptyValue");
}
extract_parameter_schema(obj);
Some(p)
}
fn extract_parameter_schema(obj: &mut Map<String, Value>) {
const SCHEMA_KEYS: &[&str] = &[
"type",
"format",
"enum",
"items",
"default",
"multipleOf",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"minLength",
"maxLength",
"pattern",
"minItems",
"maxItems",
"uniqueItems",
];
let mut schema = Map::new();
for k in SCHEMA_KEYS {
if let Some(v) = obj.remove(*k) {
schema.insert((*k).into(), v);
}
}
if let Some(items) = schema.get_mut("items") {
transform_items(items);
}
if !schema.is_empty() {
obj.insert("schema".into(), Value::Object(schema));
}
}
fn transform_items(items: &mut Value) {
let Value::Object(obj) = items else { return };
obj.remove("collectionFormat");
if let Some(inner) = obj.get_mut("items") {
transform_items(inner);
}
}
fn collection_format_to_style(cf: &str, location: &str) -> Option<(&'static str, bool)> {
match (cf, location) {
("csv", "query" | "cookie") => Some(("form", false)),
("csv", _) => Some(("simple", false)),
("ssv", "query" | "cookie") => Some(("spaceDelimited", false)),
("pipes", "query" | "cookie") => Some(("pipeDelimited", false)),
("multi", "query" | "cookie") => Some(("form", true)),
("ssv" | "pipes", _) => Some(("simple", false)),
("multi", _) => Some(("simple", true)),
_ => None,
}
}
fn wrap_named_values_as_examples(map: Map<String, Value>) -> Value {
let wrapped: Map<String, Value> = map
.into_iter()
.map(|(name, raw)| {
let mut example = Map::new();
example.insert("value".into(), raw);
(name, Value::Object(example))
})
.collect();
Value::Object(wrapped)
}
fn build_body_request_body(body: Option<Value>, consumes: &[String]) -> Option<Value> {
let body = body?;
if body.as_object().is_some_and(|o| o.contains_key("$ref")) {
return Some(body);
}
let Value::Object(mut p) = body else {
return None;
};
let description = p.remove("description");
let required = p.remove("required");
let schema = p.remove("schema");
let examples = p.remove("x-examples").and_then(|v| match v {
Value::Object(map) => Some(wrap_named_values_as_examples(map)),
_ => None,
});
let mut content = Map::new();
let mut mime_types = if consumes.is_empty() {
vec!["application/json".to_owned()]
} else {
consumes.to_vec()
};
let last_mime = mime_types.pop();
for mime in mime_types {
let mut media = Map::new();
if let Some(s) = &schema {
media.insert("schema".into(), s.clone());
}
if let Some(ex) = &examples {
media.insert("examples".into(), ex.clone());
}
content.insert(mime, Value::Object(media));
}
if let Some(mime) = last_mime {
let mut media = Map::new();
if let Some(s) = schema {
media.insert("schema".into(), s);
}
if let Some(ex) = examples {
media.insert("examples".into(), ex);
}
content.insert(mime, Value::Object(media));
}
let mut out = Map::new();
if let Some(d) = description {
out.insert("description".into(), d);
}
if let Some(r) = required {
out.insert("required".into(), r);
}
out.insert("content".into(), Value::Object(content));
Some(Value::Object(out))
}
fn build_form_request_body_or_ref(
form_params: Vec<Value>,
consumes: &[String],
form_param_defs: &Map<String, Value>,
) -> Option<Value> {
if form_params.len() == 1
&& form_params[0]
.as_object()
.is_some_and(|o| o.contains_key("$ref"))
{
return form_params.into_iter().next();
}
let mut resolved = Vec::with_capacity(form_params.len());
for p in form_params {
match p.as_object() {
Some(o) if o.contains_key("$ref") => {
if let Some(name) = ref_local_name(&p, "#/parameters/")
&& let Some(def) = form_param_defs.get(name)
{
resolved.push(def.clone());
}
}
_ => resolved.push(p),
}
}
build_form_request_body(resolved, consumes)
}
fn merge_form_params(base: &[Value], overrides: Vec<Value>) -> Vec<Value> {
let override_names: HashSet<String> = overrides
.iter()
.filter_map(|p| {
p.get("name")
.and_then(|v| v.as_str())
.map(str::to_owned)
.or_else(|| ref_local_name(p, "#/parameters/").map(str::to_owned))
})
.collect();
let mut out = Vec::with_capacity(base.len() + overrides.len());
for p in base {
let key = p
.get("name")
.and_then(|v| v.as_str())
.map(str::to_owned)
.or_else(|| ref_local_name(p, "#/parameters/").map(str::to_owned));
if let Some(k) = key
&& override_names.contains(&k)
{
continue;
}
out.push(p.clone());
}
out.extend(overrides);
out
}
fn build_form_request_body(form_params: Vec<Value>, consumes: &[String]) -> Option<Value> {
if form_params.is_empty() {
return None;
}
let any_file = form_params
.iter()
.any(|p| p.get("type").and_then(|v| v.as_str()) == Some("file"));
let mime_types: Vec<String> = if !consumes.is_empty() {
consumes.to_vec()
} else if any_file {
vec!["multipart/form-data".to_owned()]
} else {
vec!["application/x-www-form-urlencoded".to_owned()]
};
let mut properties = Map::new();
let mut required = Vec::new();
for p in form_params {
let Value::Object(mut p_obj) = p else {
continue;
};
let name = match p_obj.remove("name").and_then(string) {
Some(n) => n,
None => continue,
};
if p_obj.remove("required").and_then(|v| v.as_bool()) == Some(true) {
required.push(Value::String(name.clone()));
}
for k in &["in", "allowEmptyValue", "collectionFormat"] {
p_obj.remove(*k);
}
if p_obj.get("type").and_then(|v| v.as_str()) == Some("file") {
p_obj.insert("type".into(), Value::String("string".into()));
p_obj.insert("format".into(), Value::String("binary".into()));
}
if let Some(items) = p_obj.get_mut("items") {
transform_items(items);
}
properties.insert(name, Value::Object(p_obj));
}
let mut schema = Map::new();
schema.insert("type".into(), Value::String("object".into()));
schema.insert("properties".into(), Value::Object(properties));
if !required.is_empty() {
schema.insert("required".into(), Value::Array(required));
}
let mut content = Map::new();
let mut mime_types = mime_types;
let last_mime = mime_types.pop();
for mime in mime_types {
let mut media = Map::new();
media.insert("schema".into(), Value::Object(schema.clone()));
content.insert(mime, Value::Object(media));
}
if let Some(mime) = last_mime {
let mut media = Map::new();
media.insert("schema".into(), Value::Object(schema));
content.insert(mime, Value::Object(media));
}
let mut out = Map::new();
out.insert("content".into(), Value::Object(content));
Some(Value::Object(out))
}
fn transform_response(resp: &mut Value, produces: &[String]) {
if resp.as_object().is_some_and(|o| o.contains_key("$ref")) {
return;
}
let Value::Object(obj) = resp else { return };
if let Some(Value::Object(headers)) = obj.get_mut("headers") {
for (_, h) in headers.iter_mut() {
transform_header(h);
}
}
let schema = obj.remove("schema");
let examples = obj.remove("examples");
if schema.is_some() || examples.is_some() {
let mime_types = if !produces.is_empty() {
produces.to_vec()
} else {
vec!["application/json".to_owned()]
};
let mut content = Map::new();
let example_map = match examples {
Some(Value::Object(m)) => m,
_ => Map::new(),
};
for mime in &mime_types {
let mut media = Map::new();
if let Some(s) = &schema {
media.insert("schema".into(), s.clone());
}
if let Some(ex) = example_map.get(mime) {
media.insert("example".into(), ex.clone());
}
content.insert(mime.clone(), Value::Object(media));
}
for (mime, ex) in &example_map {
if !content.contains_key(mime) {
let mut media = Map::new();
if let Some(s) = &schema {
media.insert("schema".into(), s.clone());
}
media.insert("example".into(), ex.clone());
content.insert(mime.clone(), Value::Object(media));
}
}
if !content.is_empty() {
obj.insert("content".into(), Value::Object(content));
}
}
}
fn transform_header(header: &mut Value) {
if header.as_object().is_some_and(|o| o.contains_key("$ref")) {
return;
}
let Value::Object(obj) = header else { return };
obj.remove("collectionFormat");
extract_parameter_schema(obj);
}
fn transform_security_definitions(value: &mut Value) {
let Value::Object(map) = value else { return };
for (_, scheme) in map.iter_mut() {
let Value::Object(s) = scheme else { continue };
match s.get("type").and_then(|v| v.as_str()) {
Some("basic") => {
s.insert("type".into(), Value::String("http".into()));
s.insert("scheme".into(), Value::String("basic".into()));
}
Some("oauth2") => {
let flow = s.remove("flow").and_then(string);
let auth_url = s.remove("authorizationUrl");
let token_url = s.remove("tokenUrl");
let scopes = s
.remove("scopes")
.unwrap_or_else(|| Value::Object(Map::new()));
let flow_key = match flow.as_deref() {
Some("application") => "clientCredentials",
Some("accessCode") => "authorizationCode",
Some("implicit") => "implicit",
Some("password") => "password",
_ => "implicit",
};
let mut flow_obj = Map::new();
if let Some(u) = auth_url {
flow_obj.insert("authorizationUrl".into(), u);
}
if let Some(u) = token_url {
flow_obj.insert("tokenUrl".into(), u);
}
flow_obj.insert("scopes".into(), scopes);
let mut flows = Map::new();
flows.insert(flow_key.into(), Value::Object(flow_obj));
s.insert("flows".into(), Value::Object(flows));
}
_ => {}
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Pos {
Generic,
Schema,
SchemaMap,
Link,
LinkMap,
}
fn walk(
value: &mut Value,
body_param_names: &HashSet<String>,
form_param_names: &HashSet<String>,
pos: Pos,
) {
match value {
Value::Object(obj) => match pos {
Pos::Schema => walk_schema_object(obj, body_param_names, form_param_names),
Pos::SchemaMap => {
for (_, v) in obj.iter_mut() {
walk(v, body_param_names, form_param_names, Pos::Schema);
}
}
Pos::Link => walk_link_object(obj, body_param_names, form_param_names),
Pos::LinkMap => {
for (_, v) in obj.iter_mut() {
walk(v, body_param_names, form_param_names, Pos::Link);
}
}
Pos::Generic => walk_generic_object(obj, body_param_names, form_param_names),
},
Value::Array(arr) => {
for v in arr.iter_mut() {
walk(v, body_param_names, form_param_names, pos);
}
}
_ => {}
}
}
fn walk_schema_object(
obj: &mut Map<String, Value>,
body_param_names: &HashSet<String>,
form_param_names: &HashSet<String>,
) {
remap_ref_in_place(obj, body_param_names, form_param_names);
if let Some(v) = obj.remove("x-nullable") {
obj.insert("nullable".into(), v);
}
if let Some(Value::String(name)) = obj.get("discriminator").cloned() {
let mut d = Map::new();
d.insert("propertyName".into(), Value::String(name));
obj.insert("discriminator".into(), Value::Object(d));
}
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"
| "additionalItems"
| "contains"
| "propertyNames"
| "if"
| "then"
| "else"
| "unevaluatedItems"
| "unevaluatedProperties" => walk(v, body_param_names, form_param_names, Pos::Schema),
"allOf" | "anyOf" | "oneOf" | "prefixItems" => {
walk(v, body_param_names, form_param_names, Pos::Schema)
}
"properties" | "patternProperties" | "$defs" | "definitions" | "dependentSchemas" => {
walk(v, body_param_names, form_param_names, Pos::SchemaMap)
}
"dependencies" => walk_dependencies(v, body_param_names, form_param_names),
_ => walk(v, body_param_names, form_param_names, Pos::Generic),
}
}
}
fn walk_dependencies(
value: &mut Value,
body_param_names: &HashSet<String>,
form_param_names: &HashSet<String>,
) {
let Value::Object(map) = value else { return };
for (_, entry) in map.iter_mut() {
if let Value::Object(_) = entry {
walk(entry, body_param_names, form_param_names, Pos::Schema);
}
}
}
fn walk_generic_object(
obj: &mut Map<String, Value>,
body_param_names: &HashSet<String>,
form_param_names: &HashSet<String>,
) {
remap_ref_in_place(obj, body_param_names, form_param_names);
for (k, v) in obj.iter_mut() {
if is_extension_key(k) {
continue;
}
match k.as_str() {
"schema" => walk(v, body_param_names, form_param_names, Pos::Schema),
"schemas" => walk(v, body_param_names, form_param_names, Pos::SchemaMap),
"links" => walk(v, body_param_names, form_param_names, Pos::LinkMap),
"example" | "examples" | "value" => continue,
_ => walk(v, body_param_names, form_param_names, Pos::Generic),
}
}
}
fn walk_link_object(
obj: &mut Map<String, Value>,
body_param_names: &HashSet<String>,
form_param_names: &HashSet<String>,
) {
remap_ref_in_place(obj, body_param_names, form_param_names);
for (k, v) in obj.iter_mut() {
if is_extension_key(k) {
continue;
}
match k.as_str() {
"parameters" | "requestBody" => continue,
_ => walk(v, body_param_names, form_param_names, Pos::Generic),
}
}
}
fn remap_ref_in_place(
obj: &mut Map<String, Value>,
body_param_names: &HashSet<String>,
form_param_names: &HashSet<String>,
) {
if let Some(Value::String(s)) = obj.get_mut("$ref") {
*s = remap_ref_path(s, body_param_names, form_param_names);
}
}
fn is_extension_key(k: &str) -> bool {
k.starts_with("x-")
}
fn remap_ref_path(
s: &str,
body_param_names: &HashSet<String>,
form_param_names: &HashSet<String>,
) -> String {
if let Some(rest) = s.strip_prefix("#/parameters/") {
if body_param_names.contains(rest) || form_param_names.contains(rest) {
return format!("#/components/requestBodies/{rest}");
}
return format!("#/components/parameters/{rest}");
}
const MAPPINGS: &[(&str, &str)] = &[
("#/definitions/", "#/components/schemas/"),
("#/responses/", "#/components/responses/"),
("#/securityDefinitions/", "#/components/securitySchemes/"),
];
for (old, new) in MAPPINGS {
if let Some(rest) = s.strip_prefix(old) {
return format!("{new}{rest}");
}
}
s.to_owned()
}
fn string(v: Value) -> Option<String> {
match v {
Value::String(s) => Some(s),
_ => None,
}
}
fn take_string_array(obj: &mut Map<String, Value>, key: &str) -> Vec<String> {
match obj.remove(key) {
Some(Value::Array(arr)) => arr.into_iter().filter_map(string).collect(),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::v2::spec::Spec as V2Spec;
use crate::v3_0::spec::Spec as V3Spec;
use crate::validation::Validate;
fn v2_from_json(s: &str) -> V2Spec {
serde_json::from_str(s).expect("v2 spec parses")
}
#[test]
fn all_v2_fixtures_convert_to_valid_v3_0() {
let fixtures: &[(&str, &str)] = &[
(
"petstore_minimal",
include_str!("../../tests/v2_data/petstore_minimal.json"),
),
(
"petstore-simple",
include_str!("../../tests/v2_data/petstore-simple.json"),
),
(
"petstore-with-external-docs",
include_str!("../../tests/v2_data/petstore-with-external-docs.json"),
),
(
"petstore_expanded",
include_str!("../../tests/v2_data/petstore_expanded.json"),
),
(
"petstore",
include_str!("../../tests/v2_data/petstore.json"),
),
(
"petstore_full",
include_str!("../../tests/v2_data/petstore_full.json"),
),
(
"api_with_examples",
include_str!("../../tests/v2_data/api_with_examples.json"),
),
("uber", include_str!("../../tests/v2_data/uber.json")),
];
for (name, raw) in fixtures {
let v2: V2Spec =
serde_json::from_str(raw).unwrap_or_else(|e| panic!("{name}: parse: {e}"));
let v3: V3Spec = v2.into();
assert_eq!(v3.openapi.as_str(), "3.0.4", "{name} openapi version");
let opts = crate::validation::Options::new()
| crate::validation::Options::IgnoreMissingTags
| crate::validation::IGNORE_UNUSED;
if let Err(e) = v3.validate(opts, None) {
panic!("{name}: converted spec did not validate cleanly:\n{e}");
}
}
}
#[test]
fn petstore_minimal_round_trips_to_valid_v3_0() {
let v2: V2Spec = v2_from_json(include_str!("../../tests/v2_data/petstore_minimal.json"));
let v3: V3Spec = v2.into();
assert_eq!(v3.openapi.as_str(), "3.0.4");
let servers = v3.servers.as_ref().expect("servers populated");
assert!(
servers
.iter()
.any(|s| s.url == "http://petstore.swagger.io/api")
);
let components = v3.components.as_ref().expect("components populated");
let schemas = components.schemas.as_ref().expect("schemas populated");
assert!(schemas.contains_key("Pet"));
let _ = v3.paths.iter().next().expect("at least one path");
assert!(
v3.validate(Default::default(), None).is_ok(),
"converted spec must validate clean"
);
}
#[test]
fn body_parameter_becomes_request_body() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/pets": {
"post": {
"consumes": ["application/json"],
"parameters": [{
"in": "body",
"name": "pet",
"required": true,
"schema": { "$ref": "#/definitions/Pet" }
}],
"responses": { "201": { "description": "ok" } }
}
}
},
"definitions": {
"Pet": { "type": "object" }
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let post = &value["paths"]["/pets"]["post"];
assert!(post.get("parameters").is_none(), "body param removed");
let request_body = &post["requestBody"];
assert_eq!(request_body["required"], Value::Bool(true));
let schema_ref = &request_body["content"]["application/json"]["schema"]["$ref"];
assert_eq!(schema_ref, "#/components/schemas/Pet");
}
#[test]
fn form_data_becomes_url_encoded_request_body() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/login": {
"post": {
"parameters": [
{"in":"formData","name":"username","type":"string","required":true},
{"in":"formData","name":"password","type":"string","required":true}
],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let post = &value["paths"]["/login"]["post"];
assert!(
post["parameters"].is_null(),
"formData removed from parameters"
);
let content = &post["requestBody"]["content"]["application/x-www-form-urlencoded"];
assert_eq!(content["schema"]["type"], "object");
assert_eq!(
content["schema"]["properties"]["username"]["type"],
"string"
);
let required = content["schema"]["required"].as_array().unwrap();
assert!(required.contains(&Value::String("username".into())));
}
#[test]
fn form_data_with_file_uses_multipart() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"parameters": [
{"in":"formData","name":"file","type":"file"}
],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let content = &value["paths"]["/upload"]["post"]["requestBody"]["content"];
let media = &content["multipart/form-data"]["schema"]["properties"]["file"];
assert_eq!(media["type"], "string");
assert_eq!(media["format"], "binary");
}
#[test]
fn query_parameter_gets_nested_schema_and_style() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/items": {
"get": {
"parameters": [{
"in": "query",
"name": "tags",
"type": "array",
"items": {"type": "string"},
"collectionFormat": "multi"
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/items"]["get"]["parameters"][0];
assert_eq!(p["in"], "query");
assert_eq!(p["schema"]["type"], "array");
assert_eq!(p["schema"]["items"]["type"], "string");
assert_eq!(p["style"], "form");
assert_eq!(p["explode"], true);
assert!(p.get("type").is_none(), "type folded into schema");
assert!(
p.get("collectionFormat").is_none(),
"collectionFormat removed"
);
}
#[test]
fn response_schema_and_examples_become_content_map() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"produces": ["application/json", "application/xml"],
"paths": {
"/x": {
"get": {
"responses": {
"200": {
"description": "ok",
"schema": {"type": "string"},
"examples": {
"application/json": "hi",
"text/plain": "plain"
}
}
}
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let content = &value["paths"]["/x"]["get"]["responses"]["200"]["content"];
assert_eq!(content["application/json"]["schema"]["type"], "string");
assert_eq!(content["application/xml"]["schema"]["type"], "string");
assert_eq!(content["application/json"]["example"], "hi");
assert_eq!(content["text/plain"]["example"], "plain");
}
#[test]
fn schema_example_default_payloads_are_preserved_byte_for_byte() {
let mut v: Value = serde_json::json!({
"type": "object",
"example": {
"x-nullable": true,
"discriminator": "kind",
"$ref": "#/definitions/Inner"
},
"default": {
"x-nullable": false,
"$ref": "#/definitions/Inner"
},
"enum": [
{"x-nullable": true, "$ref": "#/definitions/Other"}
]
});
let body: HashSet<String> = HashSet::new();
let form: HashSet<String> = HashSet::new();
super::walk(&mut v, &body, &form, super::Pos::Schema);
assert_eq!(v["example"]["x-nullable"], true);
assert_eq!(v["example"]["discriminator"], "kind");
assert_eq!(v["example"]["$ref"], "#/definitions/Inner");
assert_eq!(v["default"]["x-nullable"], false);
assert_eq!(v["default"]["$ref"], "#/definitions/Inner");
assert_eq!(v["enum"][0]["x-nullable"], true);
assert_eq!(v["enum"][0]["$ref"], "#/definitions/Other");
}
#[test]
fn additional_items_and_dependencies_sub_schemas_get_rewrites() {
let mut v: Value = serde_json::json!({
"type": "object",
"additionalItems": {
"type": "string",
"x-nullable": true
},
"dependencies": {
"kind": ["name", "tag"],
"extras": {
"type": "object",
"x-nullable": true,
"discriminator": "category"
}
}
});
let body: HashSet<String> = HashSet::new();
let form: HashSet<String> = HashSet::new();
super::walk(&mut v, &body, &form, super::Pos::Schema);
assert_eq!(v["additionalItems"]["nullable"], true);
assert!(v["additionalItems"].get("x-nullable").is_none());
let extras = &v["dependencies"]["extras"];
assert_eq!(extras["nullable"], true);
assert_eq!(extras["discriminator"]["propertyName"], "category");
assert!(extras.get("x-nullable").is_none());
assert_eq!(
v["dependencies"]["kind"],
serde_json::json!(["name", "tag"])
);
}
#[test]
fn x_extension_payload_is_opaque_to_walker() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"definitions": {
"Doc": {
"type": "object",
"x-trap": {
"x-nullable": true,
"discriminator": "kind",
"$ref": "#/definitions/Inner"
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let trap = &value["components"]["schemas"]["Doc"]["x-trap"];
assert_eq!(trap["x-nullable"], true);
assert_eq!(trap["discriminator"], "kind");
assert_eq!(trap["$ref"], "#/definitions/Inner");
}
#[test]
fn responses_default_status_code_still_walks_normally() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"produces": ["application/json"],
"responses": {
"default": {
"description": "err",
"schema": {"$ref": "#/definitions/Err"}
}
}
}
}
},
"definitions": {"Err": {"type": "object"}}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let schema_ref = &value["paths"]["/x"]["get"]["responses"]["default"]["content"]["application/json"]
["schema"]["$ref"];
assert_eq!(schema_ref, "#/components/schemas/Err");
}
#[test]
fn ref_paths_are_remapped_to_components() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"parameters": [{"$ref": "#/parameters/limit"}],
"responses": {
"default": {"$ref": "#/responses/Err"}
}
}
}
},
"parameters": {
"limit": {"in": "query", "name": "limit", "type": "integer"}
},
"responses": {
"Err": {"description": "err", "schema": {"$ref": "#/definitions/Err"}}
},
"definitions": {
"Err": {"type": "object"}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
assert_eq!(
value["paths"]["/x"]["get"]["parameters"][0]["$ref"],
"#/components/parameters/limit"
);
assert_eq!(
value["paths"]["/x"]["get"]["responses"]["default"]["$ref"],
"#/components/responses/Err"
);
let err_resp = &value["components"]["responses"]["Err"];
let schema_ref = &err_resp["content"]["application/json"]["schema"]["$ref"];
assert_eq!(schema_ref, "#/components/schemas/Err");
}
#[test]
fn top_level_non_body_parameter_gets_nested_schema() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/items": {
"get": {
"parameters": [{"$ref": "#/parameters/Limit"}],
"responses": { "200": { "description": "ok" } }
}
}
},
"parameters": {
"Limit": {
"in": "query",
"name": "limit",
"type": "integer",
"format": "int32"
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let limit = &value["components"]["parameters"]["Limit"];
assert_eq!(limit["in"], "query");
assert_eq!(limit["schema"]["type"], "integer");
assert_eq!(limit["schema"]["format"], "int32");
assert!(limit.get("type").is_none(), "type folded into schema");
let p_ref = &value["paths"]["/items"]["get"]["parameters"][0]["$ref"];
assert_eq!(p_ref, "#/components/parameters/Limit");
}
#[test]
fn top_level_body_parameter_becomes_request_body_component() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/pets": {
"post": {
"parameters": [{"$ref": "#/parameters/PetBody"}],
"responses": { "201": { "description": "ok" } }
}
}
},
"parameters": {
"PetBody": {
"in": "body",
"name": "pet",
"required": true,
"schema": {"$ref": "#/definitions/Pet"}
}
},
"definitions": {"Pet": {"type": "object"}}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
assert!(
value["components"]["parameters"]["PetBody"].is_null(),
"body must NOT land in components.parameters"
);
let pet_body = &value["components"]["requestBodies"]["PetBody"];
assert_eq!(pet_body["required"], true);
let schema_ref = &pet_body["content"]["application/json"]["schema"]["$ref"];
assert_eq!(schema_ref, "#/components/schemas/Pet");
let post = &value["paths"]["/pets"]["post"];
assert!(
post["parameters"].is_null(),
"body $ref removed from parameters"
);
assert_eq!(
post["requestBody"]["$ref"],
"#/components/requestBodies/PetBody"
);
}
#[test]
fn form_data_ref_becomes_request_body_ref() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"parameters": [{"$ref": "#/parameters/File"}],
"responses": { "200": { "description": "ok" } }
}
}
},
"parameters": {
"File": {"in": "formData", "name": "file", "type": "file"}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let file_rb = &value["components"]["requestBodies"]["File"];
let props = &file_rb["content"]["multipart/form-data"]["schema"]["properties"];
assert_eq!(props["file"]["format"], "binary");
let post = &value["paths"]["/upload"]["post"];
assert!(post["parameters"].is_null());
assert_eq!(
post["requestBody"]["$ref"],
"#/components/requestBodies/File"
);
}
#[test]
fn mixed_inline_and_ref_form_params_keep_every_field() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"post": {
"parameters": [
{"$ref": "#/parameters/File"},
{"in": "formData", "name": "meta", "type": "string"}
],
"responses": { "200": { "description": "ok" } }
}
}
},
"parameters": {
"File": {"in": "formData", "name": "file", "type": "file"}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let post = &value["paths"]["/upload"]["post"];
assert!(post["requestBody"].get("$ref").is_none());
let props = &post["requestBody"]["content"]["multipart/form-data"]["schema"]["properties"];
assert_eq!(props["meta"]["type"], "string");
assert_eq!(props["file"]["type"], "string");
assert_eq!(props["file"]["format"], "binary");
}
#[test]
fn path_level_form_field_merges_with_operation_form_field() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"parameters": [
{"in": "formData", "name": "file", "type": "file"}
],
"post": {
"parameters": [
{"in": "formData", "name": "meta", "type": "string"}
],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let body = &value["paths"]["/upload"]["post"]["requestBody"];
let props = &body["content"]["multipart/form-data"]["schema"]["properties"];
assert_eq!(props["meta"]["type"], "string");
assert_eq!(props["file"]["format"], "binary");
}
#[test]
fn operation_form_overrides_path_level_form_by_name() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"parameters": [
{"in": "formData", "name": "tag", "type": "string"}
],
"post": {
"parameters": [
{"in": "formData", "name": "tag", "type": "integer"}
],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let props = &value["paths"]["/x"]["post"]["requestBody"]["content"]["application/x-www-form-urlencoded"]
["schema"]["properties"];
assert_eq!(props["tag"]["type"], "integer");
}
#[test]
fn path_level_form_ref_promotes_to_each_operation() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/upload": {
"parameters": [{"$ref": "#/parameters/File"}],
"post": {
"responses": { "200": { "description": "ok" } }
}
}
},
"parameters": {
"File": {"in": "formData", "name": "file", "type": "file"}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
assert_eq!(
value["paths"]["/upload"]["post"]["requestBody"]["$ref"],
"#/components/requestBodies/File"
);
}
#[test]
fn path_level_body_promotes_to_each_operation() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/items/{id}": {
"parameters": [
{"in": "path", "name": "id", "type": "string", "required": true},
{"in": "body", "name": "patch", "schema": {"type": "object"}}
],
"put": {
"responses": { "200": { "description": "ok" } }
},
"post": {
"parameters": [
{"in": "body", "name": "create", "schema": {"type": "string"}}
],
"responses": { "201": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let put = &value["paths"]["/items/{id}"]["put"];
assert_eq!(
put["requestBody"]["content"]["application/json"]["schema"]["type"],
"object"
);
let post = &value["paths"]["/items/{id}"]["post"];
assert_eq!(
post["requestBody"]["content"]["application/json"]["schema"]["type"],
"string"
);
let path_params = &value["paths"]["/items/{id}"]["parameters"];
assert_eq!(path_params[0]["name"], "id");
assert_eq!(path_params[0]["schema"]["type"], "string");
}
#[test]
fn schemes_without_host_or_base_path_omits_servers() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"schemes": ["https"],
"paths": {}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
assert!(
v3.servers.is_none(),
"no servers expected, got {:?}",
v3.servers
);
}
#[test]
fn base_path_only_assembles_one_relative_server() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"basePath": "/v1",
"schemes": ["https", "http"],
"paths": {}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let servers = v3.servers.as_ref().expect("servers populated");
assert_eq!(servers.len(), 1, "deduped to a single relative entry");
assert_eq!(servers[0].url, "/v1");
}
#[test]
fn operation_level_schemes_become_operation_servers() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"host": "api.example.com",
"basePath": "/v1",
"schemes": ["https"],
"paths": {
"/secure": {
"get": {
"schemes": ["wss"],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let spec_url = &value["servers"][0]["url"];
assert_eq!(spec_url, "https://api.example.com/v1");
let op_url = &value["paths"]["/secure"]["get"]["servers"][0]["url"];
assert_eq!(op_url, "wss://api.example.com/v1");
}
#[test]
fn security_basic_becomes_http_basic() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"securityDefinitions": {
"auth": {"type": "basic"}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let scheme = &value["components"]["securitySchemes"]["auth"];
assert_eq!(scheme["type"], "http");
assert_eq!(scheme["scheme"], "Basic");
}
#[test]
fn security_oauth2_flow_becomes_flows_object() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"securityDefinitions": {
"oauth": {
"type": "oauth2",
"flow": "accessCode",
"authorizationUrl": "https://example.com/auth",
"tokenUrl": "https://example.com/token",
"scopes": {"read": "read access"}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let scheme = &value["components"]["securitySchemes"]["oauth"];
assert_eq!(scheme["type"], "oauth2");
let flow = &scheme["flows"]["authorizationCode"];
assert_eq!(flow["authorizationUrl"], "https://example.com/auth");
assert_eq!(flow["tokenUrl"], "https://example.com/token");
assert_eq!(flow["scopes"]["read"], "read access");
}
#[test]
fn schema_x_nullable_becomes_nullable() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"definitions": {
"Pet": {
"type": "object",
"x-nullable": true,
"properties": {"id": {"type": "integer"}}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let pet = &value["components"]["schemas"]["Pet"];
assert_eq!(pet["nullable"], true);
assert!(pet.get("x-nullable").is_none());
}
#[test]
fn allof_discriminator_string_becomes_object() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"definitions": {
"Cat": {
"allOf": [
{"$ref": "#/definitions/Pet"},
{"type": "object", "properties": {"meow": {"type": "string"}}}
],
"discriminator": "kind"
},
"Pet": {"type": "object"}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let cat = &value["components"]["schemas"]["Cat"];
assert_eq!(cat["discriminator"]["propertyName"], "kind");
}
#[test]
fn x_servers_non_empty_wins_over_assembled_servers() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"host": "api.example.com",
"basePath": "/v1",
"schemes": ["https"],
"x-servers": [{"url": "https://override.example.com"}],
"paths": {}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let servers = v3.servers.as_ref().expect("servers populated");
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "https://override.example.com");
}
#[test]
fn assemble_servers_defaults_to_https_when_no_schemes() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"host": "api.example.com",
"basePath": "/v2",
"paths": {}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let servers = v3.servers.as_ref().expect("servers populated");
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "https://api.example.com/v2");
}
#[test]
fn x_extension_key_in_paths_is_skipped() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"x-internal-note": "some extension",
"/real": {
"get": {
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
assert!(value["paths"]["/real"]["get"].is_object());
}
#[test]
fn top_level_responses_component_gets_content_map() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"produces": ["application/json"],
"responses": {
"ErrorResp": {
"description": "an error",
"schema": {"$ref": "#/definitions/Error"}
}
},
"definitions": {"Error": {"type": "object"}}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let comp_resp = &value["components"]["responses"]["ErrorResp"];
assert_eq!(comp_resp["description"], "an error");
let schema_ref = &comp_resp["content"]["application/json"]["schema"]["$ref"];
assert_eq!(schema_ref, "#/components/schemas/Error");
}
#[test]
fn body_param_with_description_and_x_examples_produces_complete_request_body() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/pets": {
"post": {
"parameters": [{
"in": "body",
"name": "pet",
"description": "A pet",
"required": true,
"schema": {"type": "object"},
"x-examples": {
"cat": {"name": "Whiskers"}
}
}],
"responses": { "201": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let rb = &value["paths"]["/pets"]["post"]["requestBody"];
assert_eq!(rb["description"], "A pet");
assert_eq!(rb["required"], true);
let examples = &rb["content"]["application/json"]["examples"];
assert_eq!(examples["cat"]["value"]["name"], "Whiskers");
}
#[test]
fn collection_format_ssv_on_path_becomes_simple() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/items/{tags}": {
"get": {
"parameters": [{
"in": "path",
"name": "tags",
"required": true,
"type": "array",
"items": {"type": "string"},
"collectionFormat": "ssv"
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/items/{tags}"]["get"]["parameters"][0];
assert_eq!(p["style"], "simple");
assert_eq!(p["explode"], false);
}
#[test]
fn collection_format_pipes_on_query_becomes_pipe_delimited() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/items": {
"get": {
"parameters": [{
"in": "query",
"name": "tags",
"type": "array",
"items": {"type": "string"},
"collectionFormat": "pipes"
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/items"]["get"]["parameters"][0];
assert_eq!(p["style"], "pipeDelimited");
}
#[test]
fn collection_format_multi_on_header_becomes_simple() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"parameters": [{
"in": "header",
"name": "X-Tags",
"type": "array",
"items": {"type": "string"},
"collectionFormat": "multi"
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/x"]["get"]["parameters"][0];
assert_eq!(p["style"], "simple");
assert_eq!(p["explode"], true);
}
#[test]
fn collection_format_csv_on_query_becomes_form() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/items": {
"get": {
"parameters": [{
"in": "query",
"name": "tags",
"type": "array",
"items": {"type": "string"},
"collectionFormat": "csv"
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/items"]["get"]["parameters"][0];
assert_eq!(p["style"], "form");
assert_eq!(p["explode"], false);
}
#[test]
fn collection_format_csv_on_path_becomes_simple() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/items/{id}": {
"get": {
"parameters": [{
"in": "path",
"name": "id",
"required": true,
"type": "array",
"items": {"type": "string"},
"collectionFormat": "csv"
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/items/{id}"]["get"]["parameters"][0];
assert_eq!(p["style"], "simple");
}
#[test]
fn collection_format_ssv_on_query_becomes_space_delimited() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/items": {
"get": {
"parameters": [{
"in": "query",
"name": "tags",
"type": "array",
"items": {"type": "string"},
"collectionFormat": "ssv"
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/items"]["get"]["parameters"][0];
assert_eq!(p["style"], "spaceDelimited");
}
#[test]
fn nested_items_collectionformat_stripped() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/items": {
"get": {
"parameters": [{
"in": "query",
"name": "tags",
"type": "array",
"collectionFormat": "csv",
"items": {
"type": "array",
"collectionFormat": "csv",
"items": {"type": "string"}
}
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/items"]["get"]["parameters"][0];
assert!(p["schema"]["items"].get("collectionFormat").is_none());
}
#[test]
fn response_header_ref_passes_through() {
let mut v: serde_json::Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"produces": ["application/json"],
"responses": {
"200": {
"description": "ok",
"headers": {
"X-Rate-Limit": {
"$ref": "#/x-headerDefs/rateLimit"
}
}
}
}
}
}
}
});
super::transform_spec(&mut v);
let header = &v["paths"]["/x"]["get"]["responses"]["200"]["headers"]["X-Rate-Limit"];
assert!(header.get("$ref").is_some());
}
#[test]
fn oauth2_implicit_flow_preserved() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"securityDefinitions": {
"oauth": {
"type": "oauth2",
"flow": "implicit",
"authorizationUrl": "https://example.com/auth",
"scopes": {"read": "read access"}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let scheme = &value["components"]["securitySchemes"]["oauth"];
assert!(scheme["flows"]["implicit"].is_object());
assert_eq!(
scheme["flows"]["implicit"]["authorizationUrl"],
"https://example.com/auth"
);
}
#[test]
fn oauth2_password_flow_preserved() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"securityDefinitions": {
"oauth": {
"type": "oauth2",
"flow": "password",
"tokenUrl": "https://example.com/token",
"scopes": {"write": "write access"}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let scheme = &value["components"]["securitySchemes"]["oauth"];
assert!(scheme["flows"]["password"].is_object());
assert_eq!(
scheme["flows"]["password"]["tokenUrl"],
"https://example.com/token"
);
}
#[test]
fn oauth2_application_flow_becomes_client_credentials() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"securityDefinitions": {
"oauth": {
"type": "oauth2",
"flow": "application",
"tokenUrl": "https://example.com/token",
"scopes": {"admin": "admin"}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let scheme = &value["components"]["securitySchemes"]["oauth"];
assert!(scheme["flows"]["clientCredentials"].is_object());
}
#[test]
fn oauth2_missing_flow_falls_back_to_implicit() {
let mut v: serde_json::Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"securityDefinitions": {
"oauth": {
"type": "oauth2",
"scopes": {}
}
}
});
super::transform_spec(&mut v);
let scheme = &v["components"]["securitySchemes"]["oauth"];
assert!(scheme["flows"]["implicit"].is_object());
}
#[test]
fn link_object_parameters_and_request_body_are_opaque() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/pets": {
"get": {
"produces": ["application/json"],
"responses": {
"200": {
"description": "ok",
"schema": {"type": "object"},
"x-links": {
"GetPetById": {
"operationId": "getPet",
"parameters": {
"petId": "#/definitions/ShouldNotBeRemapped"
},
"requestBody": "#/definitions/ShouldNotBeRemapped"
}
}
}
}
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let link = &value["paths"]["/pets"]["get"]["responses"]["200"]["x-links"]["GetPetById"];
assert_eq!(
link["parameters"]["petId"],
"#/definitions/ShouldNotBeRemapped"
);
assert_eq!(link["requestBody"], "#/definitions/ShouldNotBeRemapped");
}
#[test]
fn v3_links_in_response_use_link_map_position() {
let mut v: serde_json::Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/pets": {
"get": {
"produces": ["application/json"],
"responses": {
"200": {
"description": "ok",
"schema": {"type": "object"},
"links": {
"GetPetById": {
"operationId": "getPet",
"parameters": {
"petId": "$response.body#/id"
}
}
}
}
}
}
}
}
});
super::transform_spec(&mut v);
let param = &v["paths"]["/pets"]["get"]["responses"]["200"]["links"]["GetPetById"]["parameters"]
["petId"];
assert_eq!(param, "$response.body#/id");
}
#[test]
fn unmatched_ref_prefix_passes_through() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"parameters": [{"$ref": "external.yaml#/components/parameters/Limit"}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/x"]["get"]["parameters"][0]["$ref"];
assert_eq!(p, "external.yaml#/components/parameters/Limit");
}
#[test]
fn security_definitions_ref_remapped() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"security": [{"auth": []}],
"responses": { "200": { "description": "ok" } }
}
}
},
"securityDefinitions": {
"auth": {"type": "apiKey", "name": "api_key", "in": "header"}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
assert!(
value["components"]["securitySchemes"]["auth"].is_object(),
"auth scheme must be in securitySchemes"
);
}
#[test]
fn form_param_allowemptyvalue_and_collectionformat_stripped() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/login": {
"post": {
"parameters": [{
"in": "formData",
"name": "tags",
"type": "array",
"items": {"type": "string"},
"collectionFormat": "csv",
"allowEmptyValue": true
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let props = &value["paths"]["/login"]["post"]["requestBody"]["content"]["application/x-www-form-urlencoded"]
["schema"]["properties"];
let tags = &props["tags"];
assert!(tags.get("collectionFormat").is_none());
assert!(tags.get("allowEmptyValue").is_none());
assert_eq!(tags["type"], "array");
}
#[test]
fn allow_empty_value_stripped_from_path_param() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x/{id}": {
"get": {
"parameters": [{
"in": "path",
"name": "id",
"required": true,
"type": "string",
"allowEmptyValue": true
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/x/{id}"]["get"]["parameters"][0];
assert!(
p.get("allowEmptyValue").is_none(),
"must be stripped from path param"
);
}
#[test]
fn allow_empty_value_kept_on_query_param() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"parameters": [{
"in": "query",
"name": "q",
"type": "string",
"allowEmptyValue": true
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/x"]["get"]["parameters"][0];
assert_eq!(p["allowEmptyValue"], true);
}
#[test]
fn transform_spec_on_non_object_is_noop() {
let mut v: Value = Value::String("not-an-object".into());
super::transform_spec(&mut v);
assert_eq!(v, Value::String("not-an-object".into()));
}
#[test]
fn non_object_path_item_is_skipped() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/real": {
"get": { "responses": { "200": { "description": "ok" } } }
}
}
});
v["paths"]["__bad__"] = Value::Null;
super::transform_spec(&mut v);
assert!(v["paths"]["/real"]["get"].is_object());
}
#[test]
fn collection_format_tsv_is_dropped() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/items": {
"get": {
"parameters": [{
"in": "query",
"name": "tags",
"type": "array",
"items": {"type": "string"},
"collectionFormat": "tsv"
}],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let p = &value["paths"]["/items"]["get"]["parameters"][0];
assert!(p.get("style").is_none(), "style must not be set for tsv");
assert!(
p.get("explode").is_none(),
"explode must not be set for tsv"
);
}
#[test]
fn top_level_body_param_that_is_ref_passes_through_build_body() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {},
"parameters": {
"PetBody": {
"in": "body",
"name": "pet",
"$ref": "#/definitions/Pet"
}
},
"definitions": { "Pet": { "type": "object" } }
});
super::transform_spec(&mut v);
let rb = &v["components"]["requestBodies"]["PetBody"];
assert!(rb.is_object(), "requestBody component must exist: {rb}");
}
#[test]
fn body_param_x_examples_non_object_is_ignored() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/pets": {
"post": {
"parameters": [{
"in": "body",
"name": "pet",
"schema": {"type": "object"},
"x-examples": "not-an-object"
}],
"responses": { "201": { "description": "ok" } }
}
}
}
});
super::transform_spec(&mut v);
let media = &v["paths"]["/pets"]["post"]["requestBody"]["content"]["application/json"];
assert!(media.get("examples").is_none(), "examples must be absent");
}
#[test]
fn body_param_with_multiple_consumes_clones_schema() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/pets": {
"post": {
"consumes": ["application/json", "application/xml"],
"parameters": [{
"in": "body",
"name": "pet",
"required": true,
"schema": { "type": "object" },
"x-examples": {
"cat": { "name": "Whiskers" }
}
}],
"responses": { "201": { "description": "ok" } }
}
}
}
});
super::transform_spec(&mut v);
let content = &v["paths"]["/pets"]["post"]["requestBody"]["content"];
assert_eq!(content["application/json"]["schema"]["type"], "object");
assert_eq!(content["application/xml"]["schema"]["type"], "object");
assert!(
content["application/json"]["examples"]["cat"]["value"]["name"].is_string()
|| content["application/json"]["examples"].is_object(),
"examples must appear in non-last mime entry: {content}"
);
}
#[test]
fn non_object_form_param_is_skipped() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/login": {
"post": {
"parameters": [
{"in": "formData", "name": "username", "type": "string"},
"not-an-object"
],
"responses": { "200": { "description": "ok" } }
}
}
}
});
super::transform_spec(&mut v);
let props = &v["paths"]["/login"]["post"]["requestBody"]["content"]["application/x-www-form-urlencoded"]
["schema"]["properties"];
assert!(props["username"].is_object());
}
#[test]
fn form_param_without_name_is_skipped() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/login": {
"post": {
"parameters": [
{"in": "formData", "name": "username", "type": "string"},
{"in": "formData", "type": "string"}
],
"responses": { "200": { "description": "ok" } }
}
}
}
});
super::transform_spec(&mut v);
let props = &v["paths"]["/login"]["post"]["requestBody"]["content"]["application/x-www-form-urlencoded"]
["schema"]["properties"];
assert!(props["username"].is_object());
assert_eq!(
props.as_object().map(|m| m.len()),
Some(1),
"only username must appear, got: {props}"
);
}
#[test]
fn form_body_with_multiple_consumes_clones_schema() {
let raw = r##"{
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/login": {
"post": {
"consumes": ["multipart/form-data", "application/x-www-form-urlencoded"],
"parameters": [
{"in": "formData", "name": "username", "type": "string"},
{"in": "formData", "name": "password", "type": "string"}
],
"responses": { "200": { "description": "ok" } }
}
}
}
}"##;
let v3: V3Spec = v2_from_json(raw).into();
let value = serde_json::to_value(&v3).unwrap();
let content = &value["paths"]["/login"]["post"]["requestBody"]["content"];
assert_eq!(content["multipart/form-data"]["schema"]["type"], "object");
assert_eq!(
content["application/x-www-form-urlencoded"]["schema"]["type"],
"object"
);
}
#[test]
fn take_string_array_skips_non_string_elements() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"produces": ["application/json", 42, null],
"responses": { "200": {
"description": "ok",
"schema": { "type": "string" }
}}
}
}
}
});
super::transform_spec(&mut v);
let content = &v["paths"]["/x"]["get"]["responses"]["200"]["content"];
assert!(content["application/json"]["schema"].is_object());
assert!(
content.as_object().map(|m| m.len()) == Some(1),
"only application/json expected, got {content}"
);
}
#[test]
fn link_object_x_extension_is_skipped_in_walker() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/pets": {
"get": {
"produces": ["application/json"],
"responses": {
"200": {
"description": "ok",
"schema": { "type": "object" },
"links": {
"GetPetById": {
"operationId": "getPet",
"x-internal": {
"$ref": "#/definitions/ShouldBeOpaque",
"x-nullable": true
}
}
}
}
}
}
}
}
});
super::transform_spec(&mut v);
let ext =
&v["paths"]["/pets"]["get"]["responses"]["200"]["links"]["GetPetById"]["x-internal"];
assert_eq!(
ext["$ref"], "#/definitions/ShouldBeOpaque",
"x- payload must not be remapped"
);
assert_eq!(ext["x-nullable"], true, "x- payload must be verbatim");
}
#[test]
fn operation_with_non_object_responses_does_not_panic() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"get": {
"responses": ["not", "an", "object"]
}
}
}
});
super::transform_spec(&mut v);
assert_eq!(v["paths"]["/x"]["get"]["responses"][0], "not");
}
#[test]
fn spec_level_non_object_responses_component_does_not_panic() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"responses": ["array", "not", "object"],
"paths": {}
});
super::transform_spec(&mut v);
assert_eq!(v["components"]["responses"][0], "array");
}
#[test]
fn body_param_with_non_object_value_is_dropped() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"parameters": {
"BadBody": "this-is-a-string-not-an-object"
},
"paths": {}
});
let mut v2: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"parameters": {},
"paths": {}
});
let mut v3: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/x": {
"post": {
"parameters": [42],
"responses": { "200": { "description": "ok" } }
}
}
}
});
super::transform_spec(&mut v);
super::transform_spec(&mut v2);
super::transform_spec(&mut v3);
assert!(v3["paths"]["/x"]["post"].get("requestBody").is_none());
}
#[test]
fn form_data_all_unresolvable_refs_produce_no_request_body() {
let mut v: Value = serde_json::json!({
"swagger": "2.0",
"info": { "title": "t", "version": "1" },
"paths": {
"/login": {
"post": {
"parameters": [
{"$ref": "#/parameters/Username"}
],
"responses": { "200": { "description": "ok" } }
}
}
},
"parameters": {
"Username": {
"in": "query",
"name": "username",
"type": "string"
}
}
});
super::transform_spec(&mut v);
assert!(
v["paths"]["/login"]["post"].get("requestBody").is_none()
|| v["paths"]["/login"]["post"]["requestBody"].is_null(),
"no requestBody should be generated"
);
}
}