use crate::schema::OpenApiSchema;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(super) struct OpenApiParameter {
pub(super) name: String,
#[serde(rename = "in")]
pub(super) location: String,
pub(super) required: bool,
pub(super) schema: OpenApiSchema,
}
pub(super) fn normalize_openapi_path(path: &str) -> (String, Vec<OpenApiParameter>) {
let mut params = Vec::new();
let mut out = String::with_capacity(path.len());
let path = if path.is_empty() { "/" } else { path };
if path.starts_with('/') {
out.push('/');
}
for seg in path.split('/').filter(|s| !s.is_empty()) {
if !out.ends_with('/') {
out.push('/');
}
if let Some((name, schema_opt)) = parse_typed_param_segment(seg) {
out.push('{');
out.push_str(&name);
out.push('}');
let schema = schema_opt.unwrap_or_else(OpenApiSchema::string);
params.push(OpenApiParameter {
name,
location: "path".to_string(),
required: true,
schema,
});
} else {
out.push_str(seg);
}
}
(out, params)
}
pub(super) fn is_same_hierarchy(left: &str, right: &str) -> bool {
fn segments(path: &str) -> impl Iterator<Item = &str> {
path.split('/').filter(|segment| !segment.is_empty())
}
fn is_param(segment: &str) -> bool {
segment.starts_with('{') && segment.ends_with('}')
}
let mut left = segments(left);
let mut right = segments(right);
loop {
match (left.next(), right.next()) {
(None, None) => return true,
(Some(l), Some(r)) if is_param(l) && is_param(r) => {}
(Some(l), Some(r)) if l == r && !is_param(l) => {}
_ => return false,
}
}
}
pub(super) fn rename_path_parameters(
parameters: &mut [OpenApiParameter],
own: &[OpenApiParameter],
template: &[OpenApiParameter],
) {
if own.len() != template.len() {
return;
}
for parameter in parameters
.iter_mut()
.filter(|parameter| parameter.location == "path")
{
if let Some(to) = own
.iter()
.zip(template)
.find_map(|(own, to)| (own.name == parameter.name).then_some(&to.name))
{
parameter.name.clone_from(to);
}
}
}
fn parse_typed_param_segment(seg: &str) -> Option<(String, Option<OpenApiSchema>)> {
let inner = seg.strip_prefix('{')?.strip_suffix('}')?.trim();
let inner = inner.strip_prefix('*').unwrap_or(inner);
if inner.is_empty() {
return None;
}
let mut it = inner.split(':').map(str::trim);
let name = it.next()?.trim();
if name.is_empty() {
return None;
}
let ty = it.next().filter(|s| !s.is_empty());
let fmt = it.next().filter(|s| !s.is_empty());
if ty.is_none() {
return Some((name.to_string(), None));
}
let mut schema = match ty.unwrap() {
"integer" => OpenApiSchema::integer(),
"number" => OpenApiSchema::number(),
"boolean" => OpenApiSchema::boolean(),
"string" => OpenApiSchema::string(),
_ => return Some((name.to_string(), None)),
};
if let Some(fmt) = fmt {
schema = schema.with_format(fmt);
}
Some((name.to_string(), Some(schema)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_openapi_path_extracts_all_valid_segments() {
let (_, params) = normalize_openapi_path("/teams/{team_id}/users/{user_id}");
assert_eq!(params.len(), 2);
assert_eq!(params[0].name, "team_id");
assert_eq!(params[1].name, "user_id");
assert!(params.iter().all(|p| p.required));
assert!(params.iter().all(|p| p.location == "path"));
}
#[test]
fn normalize_openapi_path_skips_invalid_placeholders() {
let (_, params) = normalize_openapi_path("/users/{}/raw/{broken/id}");
assert!(params.is_empty());
}
#[test]
fn normalize_openapi_path_supports_typed_placeholders() {
let (_, params) = normalize_openapi_path("/users/{id:integer}/posts/{published:boolean}");
assert_eq!(params.len(), 2);
assert_eq!(params[0].name, "id");
assert_eq!(params[0].schema.schema_type.as_deref(), Some("integer"));
assert_eq!(params[1].name, "published");
assert_eq!(params[1].schema.schema_type.as_deref(), Some("boolean"));
}
#[test]
fn normalize_openapi_path_describes_a_catch_all_as_a_path_parameter() {
let (path, params) = normalize_openapi_path("/users/{id}/files/{*path}");
assert_eq!(path, "/users/{id}/files/{path}");
assert_eq!(params.len(), 2);
assert_eq!(params[1].name, "path");
assert!(params[1].required);
assert_eq!(params[1].schema.schema_type.as_deref(), Some("string"));
}
#[test]
fn normalize_openapi_path_skips_an_unnamed_catch_all() {
let (_, params) = normalize_openapi_path("/files/{*}");
assert!(params.is_empty());
}
#[test]
fn is_same_hierarchy_compares_literals_and_parameter_positions() {
assert!(is_same_hierarchy("/users/{id}", "/users/{name}"));
assert!(is_same_hierarchy("/users/{id}/posts", "/users/{uid}/posts"));
assert!(is_same_hierarchy("/", "/"));
assert!(!is_same_hierarchy("/users/{id}", "/users/me"));
assert!(!is_same_hierarchy("/users/{id}", "/users/{id}/posts"));
assert!(!is_same_hierarchy("/users/{id}/posts", "/users/{id}/likes"));
assert!(!is_same_hierarchy("/users/{id}", "/"));
}
#[test]
fn rename_path_parameters_renames_by_position() {
let (_, own) = normalize_openapi_path("/pairs/{b:integer}/{a}");
let (_, template) = normalize_openapi_path("/pairs/{a}/{b}");
let mut parameters = own.clone();
parameters.push(OpenApiParameter {
name: "a".to_string(),
location: "query".to_string(),
required: false,
schema: OpenApiSchema::string(),
});
rename_path_parameters(&mut parameters, &own, &template);
let renamed = parameters
.iter()
.map(|p| (p.name.as_str(), p.location.as_str()))
.collect::<Vec<_>>();
assert_eq!(renamed, [("a", "path"), ("b", "path"), ("a", "query")]);
assert_eq!(parameters[0].schema.schema_type.as_deref(), Some("integer"));
}
#[test]
fn normalize_openapi_path_defaults_unknown_type_to_string() {
let (_, params) = normalize_openapi_path("/items/{slug}");
assert_eq!(params.len(), 1);
assert_eq!(params[0].name, "slug");
assert_eq!(params[0].schema.schema_type.as_deref(), Some("string"));
}
}