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 utoipa::openapi::Required;
10use utoipa::openapi::path::{Parameter, ParameterIn};
11
12use super::param::{ParameterValue, ResolvedParamValue};
13use super::{ParamStyle, ParamValue};
14use crate::client::error::ApiClientError;
15use crate::client::openapi::schema::Schemas;
16
17static RE: LazyLock<Regex> =
19 LazyLock::new(|| Regex::new(r"\{(?<name>\w+)}").expect("a valid regex"));
20
21fn replace_path_param(path: &str, param_name: &str, value: &str) -> String {
22 let pattern = ["{", param_name, "}"].concat();
24 path.replace(&pattern, value)
25}
26
27fn encode_path_param_value(value: &str) -> String {
31 utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
32}
33
34#[derive(Debug, Clone, Default, derive_more::Display)]
71#[display("{path}")]
72pub struct CallPath {
73 pub(in crate::client) path: String,
75 args: IndexMap<String, ResolvedParamValue>,
77 schemas: Schemas,
79}
80
81impl CallPath {
82 pub fn add_param<T: ParameterValue>(
107 mut self,
108 name: impl Into<String>,
109 param: impl Into<ParamValue<T>>,
110 ) -> Self {
111 let name = name.into();
112 let param = param.into();
113 if let Some(resolved) = param.resolve(|value| self.schemas.add_example::<T>(value)) {
114 self.args.insert(name, resolved);
115 }
116 self
117 }
118
119 pub(in crate::client) 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(in crate::client) 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(in crate::client) struct PathResolved {
167 pub(in crate::client) 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 formatted_value = match resolved.style {
210 ParamStyle::Label => {
211 format!(".{path_value}")
213 }
214 ParamStyle::Matrix => {
215 format!(";{name}={path_value}")
217 }
218 ParamStyle::DeepObject => {
219 warn!(?resolved.style, "DeepObject style not supported for path parameters");
220 continue;
221 }
222 _ => {
223 path_value
225 }
226 };
227
228 let encoded_value = encode_path_param_value(&formatted_value);
231
232 path = replace_path_param(&path, &name, &encoded_value);
234
235 if names.is_empty() {
236 return Ok(Self { path });
237 }
238 }
239
240 Err(ApiClientError::PathUnresolved {
241 path,
242 missings: names.into_iter().collect(),
243 })
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::ParamStyle;
251
252 #[test]
253 fn should_build_call_path() {
254 let path =
255 CallPath::from("/breed/{breed}/images").add_param("breed", ParamValue::new("hound"));
256
257 insta::assert_debug_snapshot!(path, @r#"
258 CallPath {
259 path: "/breed/{breed}/images",
260 args: {
261 "breed": ResolvedParamValue {
262 value: String("hound"),
263 schema: T(
264 Object(
265 Object {
266 schema_type: Type(
267 String,
268 ),
269 title: None,
270 format: None,
271 description: None,
272 default: None,
273 enum_values: None,
274 required: [],
275 properties: {},
276 additional_properties: None,
277 property_names: None,
278 deprecated: None,
279 example: None,
280 examples: [],
281 write_only: None,
282 read_only: None,
283 xml: None,
284 multiple_of: None,
285 maximum: None,
286 minimum: None,
287 exclusive_maximum: None,
288 exclusive_minimum: None,
289 max_length: None,
290 min_length: None,
291 pattern: None,
292 max_properties: None,
293 min_properties: None,
294 extensions: None,
295 content_encoding: "",
296 content_media_type: "",
297 },
298 ),
299 ),
300 style: Default,
301 },
302 },
303 schemas: Schemas(
304 [
305 "&str",
306 ],
307 ),
308 }
309 "#);
310
311 let path_resolved = PathResolved::try_from(path).expect("full resolve");
312
313 insta::assert_debug_snapshot!(path_resolved, @r#"
314 PathResolved {
315 path: "/breed/hound/images",
316 }
317 "#);
318 }
319
320 #[test]
321 fn test_path_resolved_with_multiple_parameters() {
322 let path = CallPath::from("/users/{user_id}/posts/{post_id}")
323 .add_param("user_id", ParamValue::new(123))
324 .add_param("post_id", ParamValue::new("abc"));
325
326 let resolved = PathResolved::try_from(path).expect("should resolve");
327
328 insta::assert_debug_snapshot!(resolved, @r#"
329 PathResolved {
330 path: "/users/123/posts/abc",
331 }
332 "#);
333 }
334
335 #[test]
336 fn test_path_resolved_with_missing_parameters() {
337 let path = CallPath::from("/users/{user_id}/posts/{post_id}")
338 .add_param("user_id", ParamValue::new(123));
339 let result = PathResolved::try_from(path);
342 assert!(result.is_err());
343 }
344
345 #[test]
346 fn test_path_resolved_with_url_encoding() {
347 let path =
348 CallPath::from("/search/{query}").add_param("query", ParamValue::new("hello world"));
349
350 let resolved = PathResolved::try_from(path).expect("should resolve");
351
352 assert_eq!(resolved.path, "/search/hello%20world");
353 }
354
355 #[test]
356 fn test_path_resolved_with_special_characters() {
357 let path =
358 CallPath::from("/items/{name}").add_param("name", ParamValue::new("test@example.com"));
359
360 let resolved = PathResolved::try_from(path).expect("should resolve");
361
362 insta::assert_snapshot!(resolved.path, @"/items/test%40example%2Ecom");
363 }
364
365 #[test]
366 fn test_path_with_duplicate_parameter_names() {
367 let path = CallPath::from("/test/{id}/{id}").add_param("id", ParamValue::new(123));
368
369 let result = PathResolved::try_from(path);
372
373 assert!(result.is_ok());
375 let resolved = result.unwrap();
376 assert_eq!(resolved.path, "/test/123/123");
377 }
378
379 #[test]
380 fn test_path_with_multiple_duplicate_parameters() {
381 let path = CallPath::from("/api/{version}/users/{id}/posts/{id}/comments/{version}")
382 .add_param("version", ParamValue::new("v1"))
383 .add_param("id", ParamValue::new(456));
384
385 let result = PathResolved::try_from(path);
387
388 assert!(result.is_ok());
389 let resolved = result.unwrap();
390 assert_eq!(resolved.path, "/api/v1/users/456/posts/456/comments/v1");
391 }
392
393 #[test]
394 fn test_add_param_overwrites_existing() {
395 let path = CallPath::from("/test/{id}")
396 .add_param("id", ParamValue::new(123))
397 .add_param("id", ParamValue::new(456)); let resolved = PathResolved::try_from(path).expect("should resolve");
400 assert_eq!(resolved.path, "/test/456");
401 }
402
403 #[test]
404 fn test_path_with_array_simple_style() {
405 let path = CallPath::from("/search/{tags}").add_param(
406 "tags",
407 ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::Simple),
408 );
409
410 let resolved = PathResolved::try_from(path).expect("should resolve");
411 assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi");
412 }
413
414 #[test]
415 fn test_path_with_array_default_style() {
416 let path = CallPath::from("/search/{tags}")
417 .add_param("tags", ParamValue::new(vec!["rust", "web", "api"])); let resolved = PathResolved::try_from(path).expect("should resolve");
420 assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi"); }
422
423 #[test]
424 fn test_path_with_array_space_delimited_style() {
425 let path = CallPath::from("/search/{tags}").add_param(
426 "tags",
427 ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::SpaceDelimited),
428 );
429
430 let resolved = PathResolved::try_from(path).expect("should resolve");
431 assert_eq!(resolved.path, "/search/rust%20web%20api");
432 }
433
434 #[test]
435 fn test_path_with_array_pipe_delimited_style() {
436 let path = CallPath::from("/search/{tags}").add_param(
437 "tags",
438 ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::PipeDelimited),
439 );
440
441 let resolved = PathResolved::try_from(path).expect("should resolve");
442 assert_eq!(resolved.path, "/search/rust%7Cweb%7Capi");
443 }
444
445 #[test]
446 fn test_path_with_label_style() {
447 let path = CallPath::from("/users/{id}")
448 .add_param("id", ParamValue::with_style(123, ParamStyle::Label));
449
450 let resolved = PathResolved::try_from(path).expect("should resolve");
451 assert_eq!(resolved.path, "/users/%2E123");
452 }
453
454 #[test]
455 fn test_path_with_label_style_array() {
456 let path = CallPath::from("/search/{tags}").add_param(
457 "tags",
458 ParamValue::with_style(vec!["rust", "web"], ParamStyle::Label),
459 );
460
461 let resolved = PathResolved::try_from(path).expect("should resolve");
462 assert_eq!(resolved.path, "/search/%2Erust%2Cweb");
463 }
464
465 #[test]
466 fn test_path_with_matrix_style() {
467 let path = CallPath::from("/users/{id}")
468 .add_param("id", ParamValue::with_style(123, ParamStyle::Matrix));
469
470 let resolved = PathResolved::try_from(path).expect("should resolve");
471 assert_eq!(resolved.path, "/users/%3Bid%3D123");
472 }
473
474 #[test]
475 fn test_path_with_matrix_style_array() {
476 let path = CallPath::from("/search/{tags}").add_param(
477 "tags",
478 ParamValue::with_style(vec!["rust", "web"], ParamStyle::Matrix),
479 );
480
481 let resolved = PathResolved::try_from(path).expect("should resolve");
482 assert_eq!(resolved.path, "/search/%3Btags%3Drust%2Cweb");
483 }
484
485 #[test]
486 fn test_path_with_mixed_array_types() {
487 let path = CallPath::from("/items/{values}").add_param(
488 "values",
489 ParamValue::with_style(vec![1, 2, 3], ParamStyle::Simple),
490 );
491
492 let resolved = PathResolved::try_from(path).expect("should resolve");
493 assert_eq!(resolved.path, "/items/1%2C2%2C3");
494 }
495
496 #[test]
497 fn test_replace_path_param_no_collision() {
498 let result = replace_path_param("/users/{user_id}/posts/{id}", "id", "123");
500 assert_eq!(result, "/users/{user_id}/posts/123");
501 }
502
503 #[test]
504 fn test_replace_path_param_substring_collision() {
505 let result = replace_path_param("/api/{user_id}/data/{id}", "id", "456");
507 assert_eq!(result, "/api/{user_id}/data/456");
508
509 let result = replace_path_param("/api/{user_id}/data/{id}", "user_id", "789");
510 assert_eq!(result, "/api/789/data/{id}");
511 }
512
513 #[test]
514 fn test_replace_path_param_exact_match_only() {
515 let result = replace_path_param("/prefix{param}suffix/{param}", "param", "value");
517 assert_eq!(result, "/prefixvaluesuffix/value");
518 }
519
520 #[test]
521 fn test_replace_path_param_multiple_occurrences() {
522 let result = replace_path_param(
524 "/api/{version}/users/{id}/posts/{id}/comments/{version}",
525 "id",
526 "123",
527 );
528 assert_eq!(
529 result,
530 "/api/{version}/users/123/posts/123/comments/{version}"
531 );
532 }
533
534 #[test]
535 fn test_replace_path_param_empty_cases() {
536 let result = replace_path_param("/users/{id}", "id", "");
538 assert_eq!(result, "/users/");
539
540 let result = replace_path_param("/users/{id}", "nonexistent", "123");
541 assert_eq!(result, "/users/{id}");
542 }
543
544 #[test]
545 fn test_replace_path_param_special_characters() {
546 let result = replace_path_param("/users/{id}", "id", "user@example.com");
548 assert_eq!(result, "/users/user@example.com");
549
550 let result = replace_path_param("/search/{query}", "query", "hello world & more");
551 assert_eq!(result, "/search/hello world & more");
552 }
553}