clawspec_core/client/parameters/
path.rs

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
17/// Regular expression for matching path parameters in the format `{param_name}`.
18static 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    // Use concat to avoid format! macro allocation, but keep str::replace for correctness
23    let pattern = ["{", param_name, "}"].concat();
24    path.replace(&pattern, value)
25}
26
27/// URL-encode a path parameter value using percent-encoding for proper path encoding.
28/// This approach maintains the existing behavior while consolidating the encoding logic
29/// in a single function that can be reused and tested independently.
30fn encode_path_param_value(value: &str) -> String {
31    utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
32}
33
34/// A parameterized HTTP path with type-safe parameter substitution.
35///
36/// `CallPath` represents an HTTP path template with named parameters that can be
37/// substituted with typed values. It supports OpenAPI 3.1 parameter styles and
38/// automatic schema generation.
39///
40/// # Examples
41///
42/// ```rust
43/// use clawspec_core::{CallPath, ParamValue};
44///
45/// // Create a path template with method chaining
46/// let path = CallPath::from("/users/{user_id}/posts/{post_id}")
47///     .add_param("user_id", ParamValue::new(123))
48///     .add_param("post_id", ParamValue::new("my-post"));
49///
50/// // Path is now ready for resolution to: /users/123/posts/my-post
51/// ```
52///
53/// # Path Template Syntax
54///
55/// Path templates use `{parameter_name}` syntax for parameter placeholders.
56/// Parameter names must be valid identifiers (alphanumeric + underscore).
57/// The same parameter can appear multiple times in a single path.
58///
59/// ```rust
60/// # use clawspec_core::{CallPath, ParamValue};
61/// let path = CallPath::from("/api/v1/users/{user_id}/documents/{doc_id}")
62///     .add_param("user_id", ParamValue::new(456))
63///     .add_param("doc_id", ParamValue::new("report-2023"));
64///
65/// // Duplicate parameters are supported
66/// let path = CallPath::from("/test/{id}/{id}")
67///     .add_param("id", ParamValue::new(123));
68/// // Results in: /test/123/123
69/// ```
70#[derive(Debug, Clone, Default, derive_more::Display)]
71#[display("{path}")]
72pub struct CallPath {
73    /// The path template with parameter placeholders
74    pub(in crate::client) path: String,
75    /// Resolved parameter values indexed by parameter name
76    args: IndexMap<String, ResolvedParamValue>,
77    /// OpenAPI schemas for the parameters
78    schemas: Schemas,
79}
80
81impl CallPath {
82    /// Adds a path parameter with the given name and value.
83    ///
84    /// This method accepts any value that can be converted into a `ParamValue<T>`,
85    /// allowing for ergonomic usage where you can pass values directly or use
86    /// explicit `ParamValue` wrappers for custom styles.
87    ///
88    /// # Parameters
89    ///
90    /// - `name`: The parameter name (will be converted to `String`)
91    /// - `param`: The parameter value that can be converted into `ParamValue<T>`
92    ///
93    /// # Examples
94    ///
95    /// ```rust
96    /// use clawspec_core::{CallPath, ParamValue, ParamStyle};
97    ///
98    /// // Ergonomic usage - pass values directly with method chaining
99    /// let path = CallPath::from("/users/{id}")
100    ///     .add_param("id", 123);
101    ///
102    /// // Explicit ParamValue usage for custom styles
103    /// let path = CallPath::from("/users/{id}")
104    ///     .add_param("id", ParamValue::with_style(456, ParamStyle::Simple));
105    /// ```
106    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    /// Creates an iterator over OpenAPI parameters for path parameters.
120    ///
121    /// This method converts the internal path parameter representation into
122    /// OpenAPI Parameter objects suitable for inclusion in the OpenAPI specification.
123    /// All path parameters are marked as required according to the OpenAPI specification.
124    ///
125    /// # Returns
126    ///
127    /// An iterator over `Parameter` objects representing path parameters.
128    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) // Path parameters are always required
134                .schema(Some(value.schema.clone()))
135                .style(value.style.into())
136                .build()
137        })
138    }
139
140    /// Get the schemas collected from path parameters.
141    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
170// Build concrete
171impl 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        // Optimized: Extract all parameter names once using a HashSet for efficient lookup
182        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        // Optimized: Process all parameters in a single pass
193        for (name, resolved) in args {
194            if !names.remove(&name) {
195                warn!(?name, "argument name not found");
196                continue;
197            }
198
199            // Convert JSON value to string for path substitution
200            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            // Apply path parameter style formatting
209            let formatted_value = match resolved.style {
210                ParamStyle::Label => {
211                    // Label style: /users/.value
212                    format!(".{path_value}")
213                }
214                ParamStyle::Matrix => {
215                    // Matrix style: /users/;name=value
216                    format!(";{name}={path_value}")
217                }
218                ParamStyle::DeepObject => {
219                    warn!(?resolved.style, "DeepObject style not supported for path parameters");
220                    continue;
221                }
222                _ => {
223                    // Default, Simple, Form, SpaceDelimited, PipeDelimited
224                    path_value
225                }
226            };
227
228            // TODO explore [URI template](https://datatracker.ietf.org/doc/html/rfc6570) - https://github.com/ilaborie/clawspec/issues/21
229            // See <https://crates.io/crates/iri-string>, <https://crates.io/crates/uri-template-system>
230            let encoded_value = encode_path_param_value(&formatted_value);
231
232            // Optimized: Use custom replacement function that avoids string allocations
233            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        // Missing post_id parameter
340
341        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        // The algorithm now properly handles duplicates using names.retain()
370        // It removes all occurrences of the parameter name from the list
371        let result = PathResolved::try_from(path);
372
373        // Should now succeed - duplicate parameters are handled correctly
374        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        // Test with multiple parameters that appear multiple times
386        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)); // Overwrite
398
399        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"])); // Default style
418
419        let resolved = PathResolved::try_from(path).expect("should resolve");
420        assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi"); // Default is Simple for paths
421    }
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        // Test that "id" doesn't match inside "user_id"
499        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        // Test parameter names that are substrings of each other
506        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        // Test that only exact {param} matches are replaced
516        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        // Test that all occurrences of the same parameter are replaced
523        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        // Test edge cases with empty values
537        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        // Test with special characters in parameter values
547        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}