clawspec_core/client/
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 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
16/// Regular expression for matching path parameters in the format `{param_name}`.
17static 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    // Use concat to avoid format! macro allocation, but keep str::replace for correctness
22    let pattern = ["{", param_name, "}"].concat();
23    path.replace(&pattern, value)
24}
25
26/// URL-encode a path parameter value using percent-encoding for proper path encoding.
27/// This approach maintains the existing behavior while consolidating the encoding logic
28/// in a single function that can be reused and tested independently.
29fn encode_path_param_value(value: &str) -> String {
30    utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
31}
32
33/// A parameterized HTTP path with type-safe parameter substitution.
34///
35/// `CallPath` represents an HTTP path template with named parameters that can be
36/// substituted with typed values. It supports OpenAPI 3.1 parameter styles and
37/// automatic schema generation.
38///
39/// # Examples
40///
41/// ```rust
42/// use clawspec_core::{CallPath, ParamValue};
43///
44/// // Create a path template
45/// let mut path = CallPath::from("/users/{user_id}/posts/{post_id}");
46///
47/// // Add typed parameters
48/// path.add_param("user_id", ParamValue::new(123));
49/// path.add_param("post_id", ParamValue::new("my-post"));
50///
51/// // Path is now ready for resolution to: /users/123/posts/my-post
52/// ```
53///
54/// # Path Template Syntax
55///
56/// Path templates use `{parameter_name}` syntax for parameter placeholders.
57/// Parameter names must be valid identifiers (alphanumeric + underscore).
58/// The same parameter can appear multiple times in a single path.
59///
60/// ```rust
61/// # use clawspec_core::{CallPath, ParamValue};
62/// let mut path = CallPath::from("/api/v1/users/{user_id}/documents/{doc_id}");
63/// path.add_param("user_id", ParamValue::new(456));
64/// path.add_param("doc_id", ParamValue::new("report-2023"));
65///
66/// // Duplicate parameters are supported
67/// let mut path = CallPath::from("/test/{id}/{id}");
68/// path.add_param("id", ParamValue::new(123));
69/// // Results in: /test/123/123
70/// ```
71#[derive(Debug, Clone, Default, derive_more::Display)]
72#[display("{path}")]
73pub struct CallPath {
74    /// The path template with parameter placeholders
75    pub(super) path: String,
76    /// Resolved parameter values indexed by parameter name
77    args: IndexMap<String, ResolvedParamValue>,
78    /// OpenAPI schemas for the parameters
79    schemas: Schemas,
80}
81
82impl CallPath {
83    /// Adds a path parameter with the given name and value.
84    ///
85    /// This method accepts any value that can be converted into a `ParamValue<T>`,
86    /// allowing for ergonomic usage where you can pass values directly or use
87    /// explicit `ParamValue` wrappers for custom styles.
88    ///
89    /// # Parameters
90    ///
91    /// - `name`: The parameter name (will be converted to `String`)
92    /// - `param`: The parameter value that can be converted into `ParamValue<T>`
93    ///
94    /// # Examples
95    ///
96    /// ```rust
97    /// use clawspec_core::{CallPath, ParamValue, ParamStyle};
98    ///
99    /// let mut path = CallPath::from("/users/{id}");
100    ///
101    /// // Ergonomic usage - pass values directly
102    /// path.add_param("id", 123);
103    ///
104    /// // Explicit ParamValue usage for custom styles
105    /// path.add_param("id", ParamValue::with_style(456, ParamStyle::Simple));
106    /// ```
107    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    /// 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(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) // 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(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
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            // TODO explore [URI template](https://datatracker.ietf.org/doc/html/rfc6570) - https://github.com/ilaborie/clawspec/issues/21
209            // See <https://crates.io/crates/iri-string>, <https://crates.io/crates/uri-template-system>
210            let encoded_value = encode_path_param_value(&path_value);
211
212            // Optimized: Use custom replacement function that avoids string allocations
213            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        // Missing post_id parameter
320
321        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        // The algorithm now properly handles duplicates using names.retain()
351        // It removes all occurrences of the parameter name from the list
352        let result = PathResolved::try_from(path);
353
354        // Should now succeed - duplicate parameters are handled correctly
355        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        // Test with multiple parameters that appear multiple times
367        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)); // Overwrite
379
380        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"])); // Default style
400
401        let resolved = PathResolved::try_from(path).expect("should resolve");
402        assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi"); // Default is Simple for paths
403    }
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        // Test that "id" doesn't match inside "user_id"
444        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        // Test parameter names that are substrings of each other
451        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        // Test that only exact {param} matches are replaced
461        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        // Test that all occurrences of the same parameter are replaced
468        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        // Test edge cases with empty values
482        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        // Test with special characters in parameter values
492        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}