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, ParamStyle, 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)]
70#[display("{path}")]
71pub struct CallPath {
72 pub(super) path: String,
74 args: IndexMap<String, ResolvedParamValue>,
76 schemas: Schemas,
78}
79
80impl CallPath {
81 pub fn add_param<T: ParameterValue>(
106 mut self,
107 name: impl Into<String>,
108 param: impl Into<ParamValue<T>>,
109 ) -> Self {
110 let name = name.into();
111 let param = param.into();
112 if let Some(resolved) = param.resolve(|value| self.schemas.add_example::<T>(value)) {
113 self.args.insert(name, resolved);
114 }
115 self
116 }
117
118 pub(super) fn to_parameters(&self) -> impl Iterator<Item = Parameter> + '_ {
128 self.args.iter().map(|(name, value)| {
129 Parameter::builder()
130 .name(name)
131 .parameter_in(ParameterIn::Path)
132 .required(Required::True) .schema(Some(value.schema.clone()))
134 .style(value.style.into())
135 .build()
136 })
137 }
138
139 pub(super) fn schemas(&self) -> &Schemas {
141 &self.schemas
142 }
143}
144
145impl From<&str> for CallPath {
146 fn from(value: &str) -> Self {
147 Self::from(value.to_string())
148 }
149}
150
151impl From<String> for CallPath {
152 fn from(value: String) -> Self {
153 let path = value;
154 let args = Default::default();
155 let schemas = Schemas::default();
156 Self {
157 path,
158 args,
159 schemas,
160 }
161 }
162}
163
164#[derive(Debug)]
165pub(super) struct PathResolved {
166 pub(super) path: String,
167}
168
169impl TryFrom<CallPath> for PathResolved {
171 type Error = ApiClientError;
172
173 fn try_from(value: CallPath) -> Result<Self, Self::Error> {
174 let CallPath {
175 mut path,
176 args,
177 schemas: _,
178 } = value;
179
180 let mut names: std::collections::HashSet<String> = RE
182 .captures_iter(&path)
183 .filter_map(|caps| caps.name("name"))
184 .map(|m| m.as_str().to_string())
185 .collect();
186
187 if names.is_empty() {
188 return Ok(Self { path });
189 }
190
191 for (name, resolved) in args {
193 if !names.remove(&name) {
194 warn!(?name, "argument name not found");
195 continue;
196 }
197
198 let path_value: String = match resolved.to_string_value() {
200 Ok(value) => value,
201 Err(err) => {
202 warn!(?resolved.value, error = %err, "failed to serialize path parameter value");
203 continue;
204 }
205 };
206
207 let formatted_value = match resolved.style {
209 ParamStyle::Label => {
210 format!(".{path_value}")
212 }
213 ParamStyle::Matrix => {
214 format!(";{name}={path_value}")
216 }
217 ParamStyle::DeepObject => {
218 warn!(?resolved.style, "DeepObject style not supported for path parameters");
219 continue;
220 }
221 _ => {
222 path_value
224 }
225 };
226
227 let encoded_value = encode_path_param_value(&formatted_value);
230
231 path = replace_path_param(&path, &name, &encoded_value);
233
234 if names.is_empty() {
235 return Ok(Self { path });
236 }
237 }
238
239 Err(ApiClientError::PathUnresolved {
240 path,
241 missings: names.into_iter().collect(),
242 })
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::ParamStyle;
250
251 #[test]
252 fn should_build_call_path() {
253 let path =
254 CallPath::from("/breed/{breed}/images").add_param("breed", ParamValue::new("hound"));
255
256 insta::assert_debug_snapshot!(path, @r#"
257 CallPath {
258 path: "/breed/{breed}/images",
259 args: {
260 "breed": ResolvedParamValue {
261 value: String("hound"),
262 schema: T(
263 Object(
264 Object {
265 schema_type: Type(
266 String,
267 ),
268 title: None,
269 format: None,
270 description: None,
271 default: None,
272 enum_values: None,
273 required: [],
274 properties: {},
275 additional_properties: None,
276 property_names: None,
277 deprecated: None,
278 example: None,
279 examples: [],
280 write_only: None,
281 read_only: None,
282 xml: None,
283 multiple_of: None,
284 maximum: None,
285 minimum: None,
286 exclusive_maximum: None,
287 exclusive_minimum: None,
288 max_length: None,
289 min_length: None,
290 pattern: None,
291 max_properties: None,
292 min_properties: None,
293 extensions: None,
294 content_encoding: "",
295 content_media_type: "",
296 },
297 ),
298 ),
299 style: Default,
300 },
301 },
302 schemas: Schemas(
303 [
304 "&str",
305 ],
306 ),
307 }
308 "#);
309
310 let path_resolved = PathResolved::try_from(path).expect("full resolve");
311
312 insta::assert_debug_snapshot!(path_resolved, @r#"
313 PathResolved {
314 path: "/breed/hound/images",
315 }
316 "#);
317 }
318
319 #[test]
320 fn test_path_resolved_with_multiple_parameters() {
321 let path = CallPath::from("/users/{user_id}/posts/{post_id}")
322 .add_param("user_id", ParamValue::new(123))
323 .add_param("post_id", ParamValue::new("abc"));
324
325 let resolved = PathResolved::try_from(path).expect("should resolve");
326
327 insta::assert_debug_snapshot!(resolved, @r#"
328 PathResolved {
329 path: "/users/123/posts/abc",
330 }
331 "#);
332 }
333
334 #[test]
335 fn test_path_resolved_with_missing_parameters() {
336 let path = CallPath::from("/users/{user_id}/posts/{post_id}")
337 .add_param("user_id", ParamValue::new(123));
338 let result = PathResolved::try_from(path);
341 assert!(result.is_err());
342 }
343
344 #[test]
345 fn test_path_resolved_with_url_encoding() {
346 let path =
347 CallPath::from("/search/{query}").add_param("query", ParamValue::new("hello world"));
348
349 let resolved = PathResolved::try_from(path).expect("should resolve");
350
351 assert_eq!(resolved.path, "/search/hello%20world");
352 }
353
354 #[test]
355 fn test_path_resolved_with_special_characters() {
356 let path =
357 CallPath::from("/items/{name}").add_param("name", ParamValue::new("test@example.com"));
358
359 let resolved = PathResolved::try_from(path).expect("should resolve");
360
361 insta::assert_snapshot!(resolved.path, @"/items/test%40example%2Ecom");
362 }
363
364 #[test]
365 fn test_path_with_duplicate_parameter_names() {
366 let path = CallPath::from("/test/{id}/{id}").add_param("id", ParamValue::new(123));
367
368 let result = PathResolved::try_from(path);
371
372 assert!(result.is_ok());
374 let resolved = result.unwrap();
375 assert_eq!(resolved.path, "/test/123/123");
376 }
377
378 #[test]
379 fn test_path_with_multiple_duplicate_parameters() {
380 let path = CallPath::from("/api/{version}/users/{id}/posts/{id}/comments/{version}")
381 .add_param("version", ParamValue::new("v1"))
382 .add_param("id", ParamValue::new(456));
383
384 let result = PathResolved::try_from(path);
386
387 assert!(result.is_ok());
388 let resolved = result.unwrap();
389 assert_eq!(resolved.path, "/api/v1/users/456/posts/456/comments/v1");
390 }
391
392 #[test]
393 fn test_add_param_overwrites_existing() {
394 let path = CallPath::from("/test/{id}")
395 .add_param("id", ParamValue::new(123))
396 .add_param("id", ParamValue::new(456)); let resolved = PathResolved::try_from(path).expect("should resolve");
399 assert_eq!(resolved.path, "/test/456");
400 }
401
402 #[test]
403 fn test_path_with_array_simple_style() {
404 let path = CallPath::from("/search/{tags}").add_param(
405 "tags",
406 ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::Simple),
407 );
408
409 let resolved = PathResolved::try_from(path).expect("should resolve");
410 assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi");
411 }
412
413 #[test]
414 fn test_path_with_array_default_style() {
415 let path = CallPath::from("/search/{tags}")
416 .add_param("tags", ParamValue::new(vec!["rust", "web", "api"])); let resolved = PathResolved::try_from(path).expect("should resolve");
419 assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi"); }
421
422 #[test]
423 fn test_path_with_array_space_delimited_style() {
424 let path = CallPath::from("/search/{tags}").add_param(
425 "tags",
426 ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::SpaceDelimited),
427 );
428
429 let resolved = PathResolved::try_from(path).expect("should resolve");
430 assert_eq!(resolved.path, "/search/rust%20web%20api");
431 }
432
433 #[test]
434 fn test_path_with_array_pipe_delimited_style() {
435 let path = CallPath::from("/search/{tags}").add_param(
436 "tags",
437 ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::PipeDelimited),
438 );
439
440 let resolved = PathResolved::try_from(path).expect("should resolve");
441 assert_eq!(resolved.path, "/search/rust%7Cweb%7Capi");
442 }
443
444 #[test]
445 fn test_path_with_label_style() {
446 let path = CallPath::from("/users/{id}")
447 .add_param("id", ParamValue::with_style(123, ParamStyle::Label));
448
449 let resolved = PathResolved::try_from(path).expect("should resolve");
450 assert_eq!(resolved.path, "/users/%2E123");
451 }
452
453 #[test]
454 fn test_path_with_label_style_array() {
455 let path = CallPath::from("/search/{tags}").add_param(
456 "tags",
457 ParamValue::with_style(vec!["rust", "web"], ParamStyle::Label),
458 );
459
460 let resolved = PathResolved::try_from(path).expect("should resolve");
461 assert_eq!(resolved.path, "/search/%2Erust%2Cweb");
462 }
463
464 #[test]
465 fn test_path_with_matrix_style() {
466 let path = CallPath::from("/users/{id}")
467 .add_param("id", ParamValue::with_style(123, ParamStyle::Matrix));
468
469 let resolved = PathResolved::try_from(path).expect("should resolve");
470 assert_eq!(resolved.path, "/users/%3Bid%3D123");
471 }
472
473 #[test]
474 fn test_path_with_matrix_style_array() {
475 let path = CallPath::from("/search/{tags}").add_param(
476 "tags",
477 ParamValue::with_style(vec!["rust", "web"], ParamStyle::Matrix),
478 );
479
480 let resolved = PathResolved::try_from(path).expect("should resolve");
481 assert_eq!(resolved.path, "/search/%3Btags%3Drust%2Cweb");
482 }
483
484 #[test]
485 fn test_path_with_mixed_array_types() {
486 let path = CallPath::from("/items/{values}").add_param(
487 "values",
488 ParamValue::with_style(vec![1, 2, 3], ParamStyle::Simple),
489 );
490
491 let resolved = PathResolved::try_from(path).expect("should resolve");
492 assert_eq!(resolved.path, "/items/1%2C2%2C3");
493 }
494
495 #[test]
496 fn test_replace_path_param_no_collision() {
497 let result = replace_path_param("/users/{user_id}/posts/{id}", "id", "123");
499 assert_eq!(result, "/users/{user_id}/posts/123");
500 }
501
502 #[test]
503 fn test_replace_path_param_substring_collision() {
504 let result = replace_path_param("/api/{user_id}/data/{id}", "id", "456");
506 assert_eq!(result, "/api/{user_id}/data/456");
507
508 let result = replace_path_param("/api/{user_id}/data/{id}", "user_id", "789");
509 assert_eq!(result, "/api/789/data/{id}");
510 }
511
512 #[test]
513 fn test_replace_path_param_exact_match_only() {
514 let result = replace_path_param("/prefix{param}suffix/{param}", "param", "value");
516 assert_eq!(result, "/prefixvaluesuffix/value");
517 }
518
519 #[test]
520 fn test_replace_path_param_multiple_occurrences() {
521 let result = replace_path_param(
523 "/api/{version}/users/{id}/posts/{id}/comments/{version}",
524 "id",
525 "123",
526 );
527 assert_eq!(
528 result,
529 "/api/{version}/users/123/posts/123/comments/{version}"
530 );
531 }
532
533 #[test]
534 fn test_replace_path_param_empty_cases() {
535 let result = replace_path_param("/users/{id}", "id", "");
537 assert_eq!(result, "/users/");
538
539 let result = replace_path_param("/users/{id}", "nonexistent", "123");
540 assert_eq!(result, "/users/{id}");
541 }
542
543 #[test]
544 fn test_replace_path_param_special_characters() {
545 let result = replace_path_param("/users/{id}", "id", "user@example.com");
547 assert_eq!(result, "/users/user@example.com");
548
549 let result = replace_path_param("/search/{query}", "query", "hello world & more");
550 assert_eq!(result, "/search/hello world & more");
551 }
552}