use std::fmt::Debug;
use std::sync::LazyLock;
use indexmap::IndexMap;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use regex::Regex;
use tracing::warn;
use utoipa::openapi::Required;
use utoipa::openapi::path::{Parameter, ParameterIn};
use super::param::{ParameterValue, ResolvedParamValue};
use super::{ParamStyle, ParamValue};
use crate::client::error::ApiClientError;
use crate::client::openapi::schema::Schemas;
static RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{(?<name>\w+)}").expect("a valid regex"));
fn replace_path_param(path: &str, param_name: &str, value: &str) -> String {
let pattern = ["{", param_name, "}"].concat();
path.replace(&pattern, value)
}
fn encode_path_param_value(value: &str) -> String {
utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
}
#[derive(Debug, Clone, Default, derive_more::Display)]
#[display("{path}")]
pub struct CallPath {
pub(in crate::client) path: String,
args: IndexMap<String, ResolvedParamValue>,
schemas: Schemas,
}
impl CallPath {
pub fn add_param<T: ParameterValue>(
mut self,
name: impl Into<String>,
param: impl Into<ParamValue<T>>,
) -> Self {
let name = name.into();
let param = param.into();
if let Some(resolved) = param.resolve(|value| self.schemas.add_example::<T>(value)) {
self.args.insert(name, resolved);
}
self
}
pub(in crate::client) fn to_parameters(&self) -> impl Iterator<Item = Parameter> + '_ {
self.args.iter().map(|(name, value)| {
Parameter::builder()
.name(name)
.parameter_in(ParameterIn::Path)
.required(Required::True) .schema(Some(value.schema.clone()))
.style(value.style.into())
.build()
})
}
pub(in crate::client) fn schemas(&self) -> &Schemas {
&self.schemas
}
}
impl From<&str> for CallPath {
fn from(value: &str) -> Self {
Self::from(value.to_string())
}
}
impl From<String> for CallPath {
fn from(value: String) -> Self {
let path = value;
let args = Default::default();
let schemas = Schemas::default();
Self {
path,
args,
schemas,
}
}
}
#[derive(Debug)]
pub(in crate::client) struct PathResolved {
pub(in crate::client) path: String,
}
impl TryFrom<CallPath> for PathResolved {
type Error = ApiClientError;
fn try_from(value: CallPath) -> Result<Self, Self::Error> {
let CallPath {
mut path,
args,
schemas: _,
} = value;
let mut names: std::collections::HashSet<String> = RE
.captures_iter(&path)
.filter_map(|caps| caps.name("name"))
.map(|m| m.as_str().to_string())
.collect();
if names.is_empty() {
return Ok(Self { path });
}
for (name, resolved) in args {
if !names.remove(&name) {
warn!(?name, "argument name not found");
continue;
}
let path_value: String = match resolved.to_string_value() {
Ok(value) => value,
Err(err) => {
warn!(?resolved.value, error = %err, "failed to serialize path parameter value");
continue;
}
};
let formatted_value = match resolved.style {
ParamStyle::Label => {
format!(".{path_value}")
}
ParamStyle::Matrix => {
format!(";{name}={path_value}")
}
ParamStyle::DeepObject => {
warn!(?resolved.style, "DeepObject style not supported for path parameters");
continue;
}
_ => {
path_value
}
};
let encoded_value = encode_path_param_value(&formatted_value);
path = replace_path_param(&path, &name, &encoded_value);
if names.is_empty() {
return Ok(Self { path });
}
}
Err(ApiClientError::PathUnresolved {
path,
missings: names.into_iter().collect(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ParamStyle;
#[test]
fn should_build_call_path() {
let path =
CallPath::from("/breed/{breed}/images").add_param("breed", ParamValue::new("hound"));
insta::assert_debug_snapshot!(path, @r#"
CallPath {
path: "/breed/{breed}/images",
args: {
"breed": ResolvedParamValue {
value: String("hound"),
schema: T(
Object(
Object {
schema_type: Type(
String,
),
title: None,
format: None,
description: None,
default: None,
enum_values: None,
required: [],
properties: {},
additional_properties: None,
property_names: None,
deprecated: None,
example: None,
examples: [],
write_only: None,
read_only: None,
xml: None,
multiple_of: None,
maximum: None,
minimum: None,
exclusive_maximum: None,
exclusive_minimum: None,
max_length: None,
min_length: None,
pattern: None,
max_properties: None,
min_properties: None,
extensions: None,
content_encoding: "",
content_media_type: "",
},
),
),
style: Default,
},
},
schemas: Schemas(
[
"&str",
],
),
}
"#);
let path_resolved = PathResolved::try_from(path).expect("full resolve");
insta::assert_debug_snapshot!(path_resolved, @r#"
PathResolved {
path: "/breed/hound/images",
}
"#);
}
#[test]
fn test_path_resolved_with_multiple_parameters() {
let path = CallPath::from("/users/{user_id}/posts/{post_id}")
.add_param("user_id", ParamValue::new(123))
.add_param("post_id", ParamValue::new("abc"));
let resolved = PathResolved::try_from(path).expect("should resolve");
insta::assert_debug_snapshot!(resolved, @r#"
PathResolved {
path: "/users/123/posts/abc",
}
"#);
}
#[test]
fn test_path_resolved_with_missing_parameters() {
let path = CallPath::from("/users/{user_id}/posts/{post_id}")
.add_param("user_id", ParamValue::new(123));
let result = PathResolved::try_from(path);
assert!(result.is_err());
}
#[test]
fn test_path_resolved_with_url_encoding() {
let path =
CallPath::from("/search/{query}").add_param("query", ParamValue::new("hello world"));
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/search/hello%20world");
}
#[test]
fn test_path_resolved_with_special_characters() {
let path =
CallPath::from("/items/{name}").add_param("name", ParamValue::new("test@example.com"));
let resolved = PathResolved::try_from(path).expect("should resolve");
insta::assert_snapshot!(resolved.path, @"/items/test%40example%2Ecom");
}
#[test]
fn test_path_with_duplicate_parameter_names() {
let path = CallPath::from("/test/{id}/{id}").add_param("id", ParamValue::new(123));
let result = PathResolved::try_from(path);
assert!(result.is_ok());
let resolved = result.unwrap();
assert_eq!(resolved.path, "/test/123/123");
}
#[test]
fn test_path_with_multiple_duplicate_parameters() {
let path = CallPath::from("/api/{version}/users/{id}/posts/{id}/comments/{version}")
.add_param("version", ParamValue::new("v1"))
.add_param("id", ParamValue::new(456));
let result = PathResolved::try_from(path);
assert!(result.is_ok());
let resolved = result.unwrap();
assert_eq!(resolved.path, "/api/v1/users/456/posts/456/comments/v1");
}
#[test]
fn test_add_param_overwrites_existing() {
let path = CallPath::from("/test/{id}")
.add_param("id", ParamValue::new(123))
.add_param("id", ParamValue::new(456));
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/test/456");
}
#[test]
fn test_path_with_array_simple_style() {
let path = CallPath::from("/search/{tags}").add_param(
"tags",
ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::Simple),
);
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi");
}
#[test]
fn test_path_with_array_default_style() {
let path = CallPath::from("/search/{tags}")
.add_param("tags", ParamValue::new(vec!["rust", "web", "api"]));
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi"); }
#[test]
fn test_path_with_array_space_delimited_style() {
let path = CallPath::from("/search/{tags}").add_param(
"tags",
ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::SpaceDelimited),
);
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/search/rust%20web%20api");
}
#[test]
fn test_path_with_array_pipe_delimited_style() {
let path = CallPath::from("/search/{tags}").add_param(
"tags",
ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::PipeDelimited),
);
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/search/rust%7Cweb%7Capi");
}
#[test]
fn test_path_with_label_style() {
let path = CallPath::from("/users/{id}")
.add_param("id", ParamValue::with_style(123, ParamStyle::Label));
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/users/%2E123");
}
#[test]
fn test_path_with_label_style_array() {
let path = CallPath::from("/search/{tags}").add_param(
"tags",
ParamValue::with_style(vec!["rust", "web"], ParamStyle::Label),
);
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/search/%2Erust%2Cweb");
}
#[test]
fn test_path_with_matrix_style() {
let path = CallPath::from("/users/{id}")
.add_param("id", ParamValue::with_style(123, ParamStyle::Matrix));
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/users/%3Bid%3D123");
}
#[test]
fn test_path_with_matrix_style_array() {
let path = CallPath::from("/search/{tags}").add_param(
"tags",
ParamValue::with_style(vec!["rust", "web"], ParamStyle::Matrix),
);
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/search/%3Btags%3Drust%2Cweb");
}
#[test]
fn test_path_with_mixed_array_types() {
let path = CallPath::from("/items/{values}").add_param(
"values",
ParamValue::with_style(vec![1, 2, 3], ParamStyle::Simple),
);
let resolved = PathResolved::try_from(path).expect("should resolve");
assert_eq!(resolved.path, "/items/1%2C2%2C3");
}
#[test]
fn test_replace_path_param_no_collision() {
let result = replace_path_param("/users/{user_id}/posts/{id}", "id", "123");
assert_eq!(result, "/users/{user_id}/posts/123");
}
#[test]
fn test_replace_path_param_substring_collision() {
let result = replace_path_param("/api/{user_id}/data/{id}", "id", "456");
assert_eq!(result, "/api/{user_id}/data/456");
let result = replace_path_param("/api/{user_id}/data/{id}", "user_id", "789");
assert_eq!(result, "/api/789/data/{id}");
}
#[test]
fn test_replace_path_param_exact_match_only() {
let result = replace_path_param("/prefix{param}suffix/{param}", "param", "value");
assert_eq!(result, "/prefixvaluesuffix/value");
}
#[test]
fn test_replace_path_param_multiple_occurrences() {
let result = replace_path_param(
"/api/{version}/users/{id}/posts/{id}/comments/{version}",
"id",
"123",
);
assert_eq!(
result,
"/api/{version}/users/123/posts/123/comments/{version}"
);
}
#[test]
fn test_replace_path_param_empty_cases() {
let result = replace_path_param("/users/{id}", "id", "");
assert_eq!(result, "/users/");
let result = replace_path_param("/users/{id}", "nonexistent", "123");
assert_eq!(result, "/users/{id}");
}
#[test]
fn test_replace_path_param_special_characters() {
let result = replace_path_param("/users/{id}", "id", "user@example.com");
assert_eq!(result, "/users/user@example.com");
let result = replace_path_param("/search/{query}", "query", "hello world & more");
assert_eq!(result, "/search/hello world & more");
}
}