use indexmap::IndexMap;
use serde_json::Value;
use crate::models::{
ApiDocumentation, OpenApiSpec, Parameter, PathItem, RequestBody, Response, Schema,
};
pub(crate) fn decode_json_pointer_token(token: &str) -> String {
token.replace("~1", "/").replace("~0", "~")
}
pub(crate) fn resolve_ref(
spec_json: &serde_json::Value,
reference: &str,
) -> Option<serde_json::Value> {
if !reference.starts_with("#/") {
return None; }
let path = &reference[2..];
let components = path.split('/');
let mut current = spec_json;
for component in components {
let unescaped = decode_json_pointer_token(component);
current = match current {
serde_json::Value::Object(obj) => obj.get(&unescaped)?,
serde_json::Value::Array(arr) => arr.get(unescaped.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(current.clone())
}
pub(crate) fn resolve_parameter_ref(
spec_json: &serde_json::Value,
parameter: &Parameter,
) -> Option<Parameter> {
if let Some(reference) = ¶meter.reference {
resolve_ref(spec_json, reference).and_then(|resolved| serde_json::from_value(resolved).ok())
} else {
Some(parameter.clone())
}
}
pub(crate) fn resolve_response_ref(
spec_json: &serde_json::Value,
response: &Response,
) -> Option<Response> {
match &response.reference {
None => Some(response.clone()),
Some(reference) => resolve_ref(spec_json, reference)
.and_then(|resolved| serde_json::from_value(resolved).ok()),
}
}
pub(crate) fn resolve_request_body_ref(
spec_json: &serde_json::Value,
request_body: &RequestBody,
) -> Option<RequestBody> {
if let Some(reference) = &request_body.reference
&& let Some(resolved) = resolve_ref(spec_json, reference)
{
return serde_json::from_value(resolved).ok();
}
Some(request_body.clone())
}
pub(crate) fn resolve_path_item_ref(
spec_json: &serde_json::Value,
path_item: &PathItem,
) -> Option<PathItem> {
match &path_item.reference {
None => Some(path_item.clone()),
Some(reference) => resolve_ref(spec_json, reference)
.and_then(|resolved| serde_json::from_value(resolved).ok()),
}
}
pub(crate) fn resolve_schema_reference<'a>(
reference: &str,
doc: &'a ApiDocumentation,
) -> Option<&'a Schema> {
if let Some(name) = reference.strip_prefix("#/components/schemas/") {
return doc.schemas.get(&decode_json_pointer_token(name));
}
if let Some(name) = reference.strip_prefix("#/definitions/") {
return doc.schemas.get(&decode_json_pointer_token(name));
}
None
}
const MAX_RESOLVE_DEPTH: usize = 64;
pub(crate) fn resolve_schema_value(schema: &Schema, doc: &ApiDocumentation) -> Value {
let value = serde_json::to_value(schema).unwrap_or(Value::Null);
let mut ref_stack = Vec::new();
inline_refs(value, doc, &mut ref_stack, 0)
}
fn inline_refs(
value: Value,
doc: &ApiDocumentation,
ref_stack: &mut Vec<String>,
depth: usize,
) -> Value {
match value {
Value::Object(mut map) => {
let reference = match map.get("$ref") {
Some(Value::String(reference)) => Some(reference.clone()),
_ => None,
};
if let Some(reference) = reference {
if depth >= MAX_RESOLVE_DEPTH || ref_stack.contains(&reference) {
return Value::Object(map);
}
let Some(target) = resolve_schema_reference(&reference, doc) else {
return Value::Object(map);
};
let mut merged = match serde_json::to_value(target) {
Ok(Value::Object(target_map)) => target_map,
_ => serde_json::Map::new(),
};
map.shift_remove("$ref");
for (key, sibling) in map {
merged.insert(key, sibling);
}
ref_stack.push(reference);
let resolved = inline_refs(Value::Object(merged), doc, ref_stack, depth + 1);
ref_stack.pop();
return resolved;
}
let mut resolved = serde_json::Map::with_capacity(map.len());
for (key, child) in map {
resolved.insert(key, inline_refs(child, doc, ref_stack, depth));
}
Value::Object(resolved)
}
Value::Array(items) => Value::Array(
items
.into_iter()
.map(|item| inline_refs(item, doc, ref_stack, depth))
.collect(),
),
other => other,
}
}
const ANNOTATION_KEYS: [&str; 5] = ["description", "title", "example", "examples", "deprecated"];
const SCHEMA_MAP_KEYS: [&str; 4] = ["properties", "patternProperties", "definitions", "$defs"];
const SCHEMA_KEYS: [&str; 3] = ["items", "additionalProperties", "not"];
const SCHEMA_LIST_KEYS: [&str; 3] = ["allOf", "oneOf", "anyOf"];
const SET_KEYS: [&str; 2] = ["required", "enum"];
pub(crate) fn canonicalize_schema_value(value: &mut Value) {
canonicalize_schema(value);
}
fn canonicalize_schema(value: &mut Value) {
let Value::Object(map) = value else {
return;
};
map.retain(|key, _| !(key.starts_with("x-") || ANNOTATION_KEYS.contains(&key.as_str())));
for (key, child) in map.iter_mut() {
if SCHEMA_MAP_KEYS.contains(&key.as_str()) {
if let Value::Object(named) = child {
for (_, nested) in named.iter_mut() {
canonicalize_schema(nested);
}
named.sort_keys();
}
} else if SCHEMA_KEYS.contains(&key.as_str()) {
canonicalize_schema(child);
} else if SCHEMA_LIST_KEYS.contains(&key.as_str()) {
if let Value::Array(items) = child {
for item in items.iter_mut() {
canonicalize_schema(item);
}
}
} else if SET_KEYS.contains(&key.as_str())
&& let Value::Array(items) = child
{
items.sort_by_cached_key(|item| item.to_string());
}
}
map.sort_keys();
}
pub(crate) fn extract_servers(spec: &OpenApiSpec) -> Vec<String> {
let mut servers = Vec::new();
if let Some(server_list) = &spec.servers {
for server in server_list {
servers.push(server.url.clone());
}
}
else if let Some(host) = spec.extensions.get("host")
&& let Some(host_str) = host.as_str()
{
let mut base_url = if host_str.starts_with("http") {
host_str.to_string()
} else {
let scheme = spec
.extensions
.get("schemes")
.and_then(|schemes| schemes.as_array())
.and_then(|schemes| schemes.first())
.and_then(|scheme| scheme.as_str())
.unwrap_or("https");
format!("{}://{}", scheme, host_str)
};
if let Some(base_path) = spec.extensions.get("basePath")
&& let Some(path_str) = base_path.as_str()
{
if !base_url.ends_with('/') && !path_str.starts_with('/') {
base_url.push('/');
}
base_url.push_str(path_str);
}
servers.push(base_url);
}
servers
}
pub(crate) fn extract_security_schemes(spec: &OpenApiSpec) -> IndexMap<String, String> {
let mut schemes = IndexMap::new();
if let Some(components) = &spec.components
&& let Some(security_schemes) = &components.security_schemes
{
for (name, scheme) in security_schemes {
let desc = format!(
"{} ({})",
scheme.description.as_deref().unwrap_or(""),
scheme.security_type
);
schemes.insert(name.clone(), desc);
}
}
if let Some(security_defs) = spec.extensions.get("securityDefinitions")
&& let Some(defs_map) = security_defs.as_object()
{
for (name, def) in defs_map {
if let Some(def_obj) = def.as_object() {
let type_str = def_obj
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("unknown");
let desc = def_obj
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("");
schemes.insert(name.clone(), format!("{} ({})", desc, type_str));
}
}
}
schemes
}
pub(crate) fn clean_for_id(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut last_was_dash = false;
for c in input.to_lowercase().chars() {
if c.is_alphanumeric() || c == '_' {
result.push(c);
last_was_dash = false;
} else if !last_was_dash {
result.push('-');
last_was_dash = true;
}
}
result.trim_matches('-').to_string()
}
pub(crate) fn extract_content_type(response: &Response) -> Option<String> {
if let Some(content) = &response.content
&& !content.is_empty()
{
return content.keys().next().map(|s| s.to_string());
}
if response.schema.is_some() {
return Some("application/json".to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::{canonicalize_schema_value, clean_for_id, resolve_schema_value};
use crate::models::{ApiDocumentation, Schema};
use indexmap::IndexMap;
use serde_json::{Value, json};
fn doc_with_schemas(schemas: &[(&str, Value)]) -> ApiDocumentation {
ApiDocumentation {
title: "Test".into(),
version: "1".into(),
description: None,
services: Vec::new(),
endpoints: Vec::new(),
servers: Vec::new(),
security_schemes: IndexMap::new(),
schemas: schemas
.iter()
.map(|(name, value)| {
(
(*name).to_string(),
serde_json::from_value::<Schema>(value.clone()).unwrap(),
)
})
.collect(),
examples: IndexMap::new(),
}
}
fn schema(value: Value) -> Schema {
serde_json::from_value(value).unwrap()
}
fn canonical(value: Value) -> Value {
let mut value = value;
canonicalize_schema_value(&mut value);
value
}
#[test]
fn resolve_schema_value_inlines_nested_component_refs() {
let doc = doc_with_schemas(&[
(
"Category",
json!({"type": "object", "properties": {"id": {"type": "string"}}}),
),
(
"CreatePetRequest",
json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"category": {"$ref": "#/components/schemas/Category"}
}
}),
),
]);
let pet = schema(json!({
"allOf": [
{"$ref": "#/components/schemas/CreatePetRequest"},
{"type": "object", "properties": {"id": {"type": "string"}}}
]
}));
let resolved = resolve_schema_value(&pet, &doc);
assert_eq!(
resolved.pointer("/allOf/0/properties/category/properties/id/type"),
Some(&json!("string"))
);
assert!(!resolved.to_string().contains("$ref"));
}
#[test]
fn resolve_schema_value_terminates_on_self_reference() {
let doc = doc_with_schemas(&[(
"Node",
json!({
"type": "object",
"properties": {
"id": {"type": "string"},
"next": {"$ref": "#/components/schemas/Node"}
}
}),
)]);
let root = schema(json!({"$ref": "#/components/schemas/Node"}));
let resolved = resolve_schema_value(&root, &doc);
assert_eq!(
resolved.pointer("/properties/id/type"),
Some(&json!("string"))
);
assert_eq!(
resolved.pointer("/properties/next"),
Some(&json!({"$ref": "#/components/schemas/Node"}))
);
}
#[test]
fn resolve_schema_value_keeps_unresolvable_ref_literal() {
let doc = doc_with_schemas(&[]);
let root = schema(json!({
"type": "object",
"properties": {"owner": {"$ref": "#/components/schemas/Missing"}}
}));
let resolved = resolve_schema_value(&root, &doc);
assert_eq!(
resolved.pointer("/properties/owner"),
Some(&json!({"$ref": "#/components/schemas/Missing"}))
);
}
#[test]
fn resolve_schema_value_merges_ref_siblings_over_target() {
let doc = doc_with_schemas(&[("Id", json!({"type": "string", "format": "uuid"}))]);
let root = schema(json!({"$ref": "#/components/schemas/Id", "format": "ulid"}));
let resolved = resolve_schema_value(&root, &doc);
assert_eq!(resolved, json!({"type": "string", "format": "ulid"}));
}
#[test]
fn resolve_schema_value_supports_swagger_definitions() {
let doc = doc_with_schemas(&[("Pet", json!({"type": "object"}))]);
let root = schema(json!({"$ref": "#/definitions/Pet"}));
assert_eq!(resolve_schema_value(&root, &doc), json!({"type": "object"}));
}
fn chain_doc(hops: usize, leaf_type: &str) -> ApiDocumentation {
let mut schemas: Vec<(String, Value)> = (0..hops)
.map(|index| {
(
format!("L{index}"),
json!({
"type": "object",
"properties": {
"next": {"$ref": format!("#/components/schemas/L{}", index + 1)}
}
}),
)
})
.collect();
schemas.push((format!("L{hops}"), json!({"type": leaf_type})));
let borrowed: Vec<(&str, Value)> = schemas
.iter()
.map(|(name, value)| (name.as_str(), value.clone()))
.collect();
doc_with_schemas(&borrowed)
}
#[test]
fn resolve_schema_value_follows_long_ref_chains() {
let root = schema(json!({"$ref": "#/components/schemas/L0"}));
let leaf = "/properties/next".repeat(12) + "/type";
let old = resolve_schema_value(&root, &chain_doc(12, "string"));
let new = resolve_schema_value(&root, &chain_doc(12, "integer"));
assert_eq!(old.pointer(&leaf), Some(&json!("string")));
assert_eq!(new.pointer(&leaf), Some(&json!("integer")));
assert_ne!(old, new);
assert!(!old.to_string().contains("$ref"));
}
#[test]
fn resolve_schema_value_stops_after_max_ref_hops() {
let root = schema(json!({"$ref": "#/components/schemas/L0"}));
let resolved = resolve_schema_value(&root, &chain_doc(70, "string"));
let last_expanded = "/properties/next".repeat(63) + "/type";
assert_eq!(resolved.pointer(&last_expanded), Some(&json!("object")));
let first_kept = "/properties/next".repeat(64);
assert_eq!(
resolved.pointer(&first_kept),
Some(&json!({"$ref": "#/components/schemas/L64"}))
);
}
#[test]
fn canonicalize_makes_required_order_irrelevant() {
let a = canonical(json!({"type": "object", "required": ["b", "a"]}));
let b = canonical(json!({"type": "object", "required": ["a", "b"]}));
assert_eq!(a, b);
}
#[test]
fn canonicalize_makes_enum_order_irrelevant() {
let a = canonical(json!({"type": "string", "enum": ["x", "y"]}));
let b = canonical(json!({"type": "string", "enum": ["y", "x"]}));
assert_eq!(a, b);
}
#[test]
fn canonicalize_ignores_description_only_changes() {
let a = canonical(json!({
"type": "object",
"title": "Old",
"description": "Old words",
"example": {"id": 1},
"x-internal": true,
"properties": {"id": {"type": "string", "description": "Old id"}}
}));
let b = canonical(json!({
"type": "object",
"title": "New",
"description": "New words",
"properties": {"id": {"type": "string", "description": "New id"}}
}));
assert_eq!(a, b);
assert_eq!(
a,
json!({"properties": {"id": {"type": "string"}}, "type": "object"})
);
}
#[test]
fn canonicalize_keeps_properties_named_like_annotations() {
let value = canonical(json!({
"type": "object",
"properties": {
"description": {"type": "string"},
"x-rate": {"type": "integer"}
}
}));
assert_eq!(
value.pointer("/properties/description/type"),
Some(&json!("string"))
);
assert_eq!(
value.pointer("/properties/x-rate/type"),
Some(&json!("integer"))
);
}
#[test]
fn canonicalize_sorts_object_keys() {
let value = canonical(json!({"type": "string", "minimum": 1, "format": "int32"}));
let keys: Vec<&String> = value.as_object().unwrap().keys().collect();
assert_eq!(keys, ["format", "minimum", "type"]);
}
#[test]
fn clean_for_id_basic_cases() {
assert_eq!(clean_for_id("Pets_ListPets"), "pets_listpets");
assert_eq!(clean_for_id("GET /pets"), "get-pets");
assert_eq!(clean_for_id("list-pets"), "list-pets");
}
#[test]
fn clean_for_id_collapses_long_dash_runs() {
assert_eq!(clean_for_id("a///b"), "a-b");
assert_eq!(clean_for_id("a / / b"), "a-b");
assert_eq!(clean_for_id("foo----bar"), "foo-bar");
}
#[test]
fn clean_for_id_trims_edge_dashes() {
assert_eq!(clean_for_id("/pets/"), "pets");
assert_eq!(clean_for_id("**bold**"), "bold");
}
}