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, ParamStyle, 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 with method chaining
45/// let path = CallPath::from("/users/{user_id}/posts/{post_id}")
46///     .add_param("user_id", ParamValue::new(123))
47///     .add_param("post_id", ParamValue::new("my-post"));
48///
49/// // Path is now ready for resolution to: /users/123/posts/my-post
50/// ```
51///
52/// # Path Template Syntax
53///
54/// Path templates use `{parameter_name}` syntax for parameter placeholders.
55/// Parameter names must be valid identifiers (alphanumeric + underscore).
56/// The same parameter can appear multiple times in a single path.
57///
58/// ```rust
59/// # use clawspec_core::{CallPath, ParamValue};
60/// let path = CallPath::from("/api/v1/users/{user_id}/documents/{doc_id}")
61///     .add_param("user_id", ParamValue::new(456))
62///     .add_param("doc_id", ParamValue::new("report-2023"));
63///
64/// // Duplicate parameters are supported
65/// let path = CallPath::from("/test/{id}/{id}")
66///     .add_param("id", ParamValue::new(123));
67/// // Results in: /test/123/123
68/// ```
69#[derive(Debug, Clone, Default, derive_more::Display)]
70#[display("{path}")]
71pub struct CallPath {
72    /// The path template with parameter placeholders
73    pub(super) path: String,
74    /// Resolved parameter values indexed by parameter name
75    args: IndexMap<String, ResolvedParamValue>,
76    /// OpenAPI schemas for the parameters
77    schemas: Schemas,
78}
79
80impl CallPath {
81    /// Adds a path parameter with the given name and value.
82    ///
83    /// This method accepts any value that can be converted into a `ParamValue<T>`,
84    /// allowing for ergonomic usage where you can pass values directly or use
85    /// explicit `ParamValue` wrappers for custom styles.
86    ///
87    /// # Parameters
88    ///
89    /// - `name`: The parameter name (will be converted to `String`)
90    /// - `param`: The parameter value that can be converted into `ParamValue<T>`
91    ///
92    /// # Examples
93    ///
94    /// ```rust
95    /// use clawspec_core::{CallPath, ParamValue, ParamStyle};
96    ///
97    /// // Ergonomic usage - pass values directly with method chaining
98    /// let path = CallPath::from("/users/{id}")
99    ///     .add_param("id", 123);
100    ///
101    /// // Explicit ParamValue usage for custom styles
102    /// let path = CallPath::from("/users/{id}")
103    ///     .add_param("id", ParamValue::with_style(456, ParamStyle::Simple));
104    /// ```
105    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    /// Creates an iterator over OpenAPI parameters for path parameters.
119    ///
120    /// This method converts the internal path parameter representation into
121    /// OpenAPI Parameter objects suitable for inclusion in the OpenAPI specification.
122    /// All path parameters are marked as required according to the OpenAPI specification.
123    ///
124    /// # Returns
125    ///
126    /// An iterator over `Parameter` objects representing path parameters.
127    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) // Path parameters are always required
133                .schema(Some(value.schema.clone()))
134                .style(value.style.into())
135                .build()
136        })
137    }
138
139    /// Get the schemas collected from path parameters.
140    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
169// Build concrete
170impl 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        // Optimized: Extract all parameter names once using a HashSet for efficient lookup
181        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        // Optimized: Process all parameters in a single pass
192        for (name, resolved) in args {
193            if !names.remove(&name) {
194                warn!(?name, "argument name not found");
195                continue;
196            }
197
198            // Convert JSON value to string for path substitution
199            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            // Apply path parameter style formatting
208            let formatted_value = match resolved.style {
209                ParamStyle::Label => {
210                    // Label style: /users/.value
211                    format!(".{path_value}")
212                }
213                ParamStyle::Matrix => {
214                    // Matrix style: /users/;name=value
215                    format!(";{name}={path_value}")
216                }
217                ParamStyle::DeepObject => {
218                    warn!(?resolved.style, "DeepObject style not supported for path parameters");
219                    continue;
220                }
221                _ => {
222                    // Default, Simple, Form, SpaceDelimited, PipeDelimited
223                    path_value
224                }
225            };
226
227            // TODO explore [URI template](https://datatracker.ietf.org/doc/html/rfc6570) - https://github.com/ilaborie/clawspec/issues/21
228            // See <https://crates.io/crates/iri-string>, <https://crates.io/crates/uri-template-system>
229            let encoded_value = encode_path_param_value(&formatted_value);
230
231            // Optimized: Use custom replacement function that avoids string allocations
232            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        // Missing post_id parameter
339
340        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        // The algorithm now properly handles duplicates using names.retain()
369        // It removes all occurrences of the parameter name from the list
370        let result = PathResolved::try_from(path);
371
372        // Should now succeed - duplicate parameters are handled correctly
373        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        // Test with multiple parameters that appear multiple times
385        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)); // Overwrite
397
398        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"])); // Default style
417
418        let resolved = PathResolved::try_from(path).expect("should resolve");
419        assert_eq!(resolved.path, "/search/rust%2Cweb%2Capi"); // Default is Simple for paths
420    }
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        // Test that "id" doesn't match inside "user_id"
498        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        // Test parameter names that are substrings of each other
505        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        // Test that only exact {param} matches are replaced
515        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        // Test that all occurrences of the same parameter are replaced
522        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        // Test edge cases with empty values
536        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        // Test with special characters in parameter values
546        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}