1use std::fmt::Debug;
2use std::sync::LazyLock;
3
4use indexmap::IndexMap;
5use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
6use regex::Regex;
7use tracing::warn;
8
9use super::param::ParameterValue;
10use super::param::ResolvedParamValue;
11use super::schema::Schemas;
12use super::{ApiClientError, ParamValue};
13use utoipa::openapi::Required;
14use utoipa::openapi::path::{Parameter, ParameterIn};
15
16static RE: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"\{(?<name>\w+)}").expect("a valid regex"));
19
20fn replace_path_param(path: &str, param_name: &str, value: &str) -> String {
21 let pattern = ["{", param_name, "}"].concat();
23 path.replace(&pattern, value)
24}
25
26fn encode_path_param_value(value: &str) -> String {
30 utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
31}
32
33#[derive(Debug, Clone, Default, derive_more::Display)]
72#[display("{path}")]
73pub struct CallPath {
74 pub(super) path: String,
76 args: IndexMap<String, ResolvedParamValue>,
78 schemas: Schemas,
80}
81
82impl CallPath {
83 pub fn add_param<T: ParameterValue>(
108 &mut self,
109 name: impl Into<String>,
110 param: impl Into<ParamValue<T>>,
111 ) {
112 let name = name.into();
113 let param = param.into();
114 if let Some(resolved) = param.resolve(|value| self.schemas.add_example::<T>(value)) {
115 self.args.insert(name, resolved);
116 }
117 }
118
119 pub(super) fn to_parameters(&self) -> impl Iterator<Item = Parameter> + '_ {
129 self.args.iter().map(|(name, value)| {
130 Parameter::builder()
131 .name(name)
132 .parameter_in(ParameterIn::Path)
133 .required(Required::True) .schema(Some(value.schema.clone()))
135 .style(value.style.into())
136 .build()
137 })
138 }
139
140 pub(super) fn schemas(&self) -> &Schemas {
142 &self.schemas
143 }
144}
145
146impl From<&str> for CallPath {
147 fn from(value: &str) -> Self {
148 Self::from(value.to_string())
149 }
150}
151
152impl From<String> for CallPath {
153 fn from(value: String) -> Self {
154 let path = value;
155 let args = Default::default();
156 let schemas = Schemas::default();
157 Self {
158 path,
159 args,
160 schemas,
161 }
162 }
163}
164
165#[derive(Debug)]
166pub(super) struct PathResolved {
167 pub(super) path: String,
168}
169
170impl TryFrom<CallPath> for PathResolved {
172 type Error = ApiClientError;
173
174 fn try_from(value: CallPath) -> Result<Self, Self::Error> {
175 let CallPath {
176 mut path,
177 args,
178 schemas: _,
179 } = value;
180
181 let mut names: std::collections::HashSet<String> = RE
183 .captures_iter(&path)
184 .filter_map(|caps| caps.name("name"))
185 .map(|m| m.as_str().to_string())
186 .collect();
187
188 if names.is_empty() {
189 return Ok(Self { path });
190 }
191
192 for (name, resolved) in args {
194 if !names.remove(&name) {
195 warn!(?name, "argument name not found");
196 continue;
197 }
198
199 let path_value: String = match resolved.to_string_value() {
201 Ok(value) => value,
202 Err(err) => {
203 warn!(?resolved.value, error = %err, "failed to serialize path parameter value");
204 continue;
205 }
206 };
207
208 let encoded_value = encode_path_param_value(&path_value);
211
212 path = replace_path_param(&path, &name, &encoded_value);
214
215 if names.is_empty() {
216 return Ok(Self { path });
217 }
218 }
219
220 Err(ApiClientError::PathUnresolved {
221 path,
222 missings: names.into_iter().collect(),
223 })
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::ParamStyle;
231
232 #[test]
233 fn should_build_call_path() {
234 let mut path = CallPath::from("/breed/{breed}/images");
235 path.add_param("breed", ParamValue::new("hound"));
236
237 insta::assert_debug_snapshot!(path, @r#"
238 CallPath {
239 path: "/breed/{breed}/images",
240 args: {
241 "breed": ResolvedParamValue {
242 value: String("hound"),
243 schema: T(
244 Object(
245 Object {
246 schema_type: Type(
247 String,
248 ),
249 title: None,
250 format: None,
251 description: None,
252 default: None,
253 enum_values: None,
254 required: [],
255 properties: {},
256 additional_properties: None,
257 property_names: None,
258 deprecated: None,
259 example: None,
260 examples: [],
261 write_only: None,
262 read_only: None,
263 xml: None,
264 multiple_of: None,
265 maximum: None,
266 minimum: None,
267 exclusive_maximum: None,
268 exclusive_minimum: None,
269 max_length: None,
270 min_length: None,
271 pattern: None,
272 max_properties: None,
273 min_properties: None,
274 extensions: None,
275 content_encoding: "",
276 content_media_type: "",
277 },
278 ),
279 ),
280 style: Default,
281 },
282 },
283 schemas: Schemas(
284 [
285 "&str",
286 ],
287 ),
288 }
289 "#);
290
291 let path_resolved = PathResolved::try_from(path).expect("full resolve");
292
293 insta::assert_debug_snapshot!(path_resolved, @r#"
294 PathResolved {
295 path: "/breed/hound/images",
296 }
297 "#);
298 }
299
300 #[test]
301 fn test_path_resolved_with_multiple_parameters() {
302 let mut path = CallPath::from("/users/{user_id}/posts/{post_id}");
303 path.add_param("user_id", ParamValue::new(123));
304 path.add_param("post_id", ParamValue::new("abc"));
305
306 let resolved = PathResolved::try_from(path).expect("should resolve");
307
308 insta::assert_debug_snapshot!(resolved, @r#"
309 PathResolved {
310 path: "/users/123/posts/abc",
311 }
312 "#);
313 }
314
315 #[test]
316 fn test_path_resolved_with_missing_parameters() {
317 let mut path = CallPath::from("/users/{user_id}/posts/{post_id}");
318 path.add_param("user_id", ParamValue::new(123));
319 let result = PathResolved::try_from(path);
322 assert!(result.is_err());
323 }
324
325 #[test]
326 fn test_path_resolved_with_url_encoding() {
327 let mut path = CallPath::from("/search/{query}");
328 path.add_param("query", ParamValue::new("hello world"));
329
330 let resolved = PathResolved::try_from(path).expect("should resolve");
331
332 assert_eq!(resolved.path, "/search/hello%20world");
333 }
334
335 #[test]
336 fn test_path_resolved_with_special_characters() {
337 let mut path = CallPath::from("/items/{name}");
338 path.add_param("name", ParamValue::new("test@example.com"));
339
340 let resolved = PathResolved::try_from(path).expect("should resolve");
341
342 insta::assert_snapshot!(resolved.path, @"/items/test%40example%2Ecom");
343 }
344
345 #[test]
346 fn test_path_with_duplicate_parameter_names() {
347 let mut path = CallPath::from("/test/{id}/{id}");
348 path.add_param("id", ParamValue::new(123));
349
350 let result = PathResolved::try_from(path);
353
354 assert!(result.is_ok());
356 let resolved = result.unwrap();
357 assert_eq!(resolved.path, "/test/123/123");
358 }
359
360 #[test]
361 fn test_path_with_multiple_duplicate_parameters() {
362 let mut path = CallPath::from("/api/{version}/users/{id}/posts/{id}/comments/{version}");
363 path.add_param("version", ParamValue::new("v1"));
364 path.add_param("id", ParamValue::new(456));
365
366 let result = PathResolved::try_from(path);
368
369 assert!(result.is_ok());
370 let resolved = result.unwrap();
371 assert_eq!(resolved.path, "/api/v1/users/456/posts/456/comments/v1");
372 }
373
374 #[test]
375 fn test_add_param_overwrites_existing() {
376 let mut path = CallPath::from("/test/{id}");
377 path.add_param("id", ParamValue::new(123));
378 path.add_param("id", ParamValue::new(456)); let resolved = PathResolved::try_from(path).expect("should resolve");
381 assert_eq!(resolved.path, "/test/456");
382 }
383
384 #[test]
385 fn test_path_with_array_simple_style() {
386 let mut path = CallPath::from("/search/{tags}");
387 path.add_param(
388 "tags",
389 ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::Simple),
390 );
391
392 let resolved = PathResolved::try_from(path).expect("should resolve");
393 assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi");
394 }
395
396 #[test]
397 fn test_path_with_array_default_style() {
398 let mut path = CallPath::from("/search/{tags}");
399 path.add_param("tags", ParamValue::new(vec!["rust", "web", "api"])); let resolved = PathResolved::try_from(path).expect("should resolve");
402 assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi"); }
404
405 #[test]
406 fn test_path_with_array_space_delimited_style() {
407 let mut path = CallPath::from("/search/{tags}");
408 path.add_param(
409 "tags",
410 ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::SpaceDelimited),
411 );
412
413 let resolved = PathResolved::try_from(path).expect("should resolve");
414 assert_eq!(resolved.path, "/search/rust%20web%20api");
415 }
416
417 #[test]
418 fn test_path_with_array_pipe_delimited_style() {
419 let mut path = CallPath::from("/search/{tags}");
420 path.add_param(
421 "tags",
422 ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::PipeDelimited),
423 );
424
425 let resolved = PathResolved::try_from(path).expect("should resolve");
426 assert_eq!(resolved.path, "/search/rust%7Cweb%7Capi");
427 }
428
429 #[test]
430 fn test_path_with_mixed_array_types() {
431 let mut path = CallPath::from("/items/{values}");
432 path.add_param(
433 "values",
434 ParamValue::with_style(vec![1, 2, 3], ParamStyle::Simple),
435 );
436
437 let resolved = PathResolved::try_from(path).expect("should resolve");
438 assert_eq!(resolved.path, "/items/1%2C2%2C3");
439 }
440
441 #[test]
442 fn test_replace_path_param_no_collision() {
443 let result = replace_path_param("/users/{user_id}/posts/{id}", "id", "123");
445 assert_eq!(result, "/users/{user_id}/posts/123");
446 }
447
448 #[test]
449 fn test_replace_path_param_substring_collision() {
450 let result = replace_path_param("/api/{user_id}/data/{id}", "id", "456");
452 assert_eq!(result, "/api/{user_id}/data/456");
453
454 let result = replace_path_param("/api/{user_id}/data/{id}", "user_id", "789");
455 assert_eq!(result, "/api/789/data/{id}");
456 }
457
458 #[test]
459 fn test_replace_path_param_exact_match_only() {
460 let result = replace_path_param("/prefix{param}suffix/{param}", "param", "value");
462 assert_eq!(result, "/prefixvaluesuffix/value");
463 }
464
465 #[test]
466 fn test_replace_path_param_multiple_occurrences() {
467 let result = replace_path_param(
469 "/api/{version}/users/{id}/posts/{id}/comments/{version}",
470 "id",
471 "123",
472 );
473 assert_eq!(
474 result,
475 "/api/{version}/users/123/posts/123/comments/{version}"
476 );
477 }
478
479 #[test]
480 fn test_replace_path_param_empty_cases() {
481 let result = replace_path_param("/users/{id}", "id", "");
483 assert_eq!(result, "/users/");
484
485 let result = replace_path_param("/users/{id}", "nonexistent", "123");
486 assert_eq!(result, "/users/{id}");
487 }
488
489 #[test]
490 fn test_replace_path_param_special_characters() {
491 let result = replace_path_param("/users/{id}", "id", "user@example.com");
493 assert_eq!(result, "/users/user@example.com");
494
495 let result = replace_path_param("/search/{query}", "query", "hello world & more");
496 assert_eq!(result, "/search/hello world & more");
497 }
498}