Repository: netwo-io/apistos (rust)

Issue:
Paths with regex
Hello, 

I don't know if it's an Apistos issue or swagger, but when using path containing regex like this one: 

```rust
scope("/imgsrv").service(resource("{path:.+}/tile").route(get().to(endpoints::tile::<T>)))
```

It generate this swagger: 

![image](https://github.com/user-attachments/assets/24a2fb62-5f9e-4271-b15a-2a150d8526be)

As you can see the curl request is wrong, the `{path:.+}` is not replaced by `test.tif`. 
It works well if I use the syntax `{path}`.
One way to fix it may be to sanitize before generating the openapi.json WDYT ? 

Thank you 

Pull request:
Feat: Handle pattern for path parameters
Fix:
- Path containing patterned parameters 

Feat:
- Add pattern for parameter name definition

(fix #119)

Patch:
diff --git a/apistos/src/app.rs b/apistos/src/app.rs
index 4114851..09f8ee7 100644
--- a/apistos/src/app.rs
+++ b/apistos/src/app.rs
@@ -271,7 +271,8 @@ where
         op.operation_id = op.operation_id.clone().or(Some(operation_id));
       });
 
-      paths.insert(path, item);
+      let sanitized_path = sanitize_patterned_path_parameter(&path);
+      paths.insert(sanitized_path, item);
     }
 
     if !self.default_parameters.is_empty() {
@@ -320,6 +321,18 @@ where
   }
 }
 
+#[allow(clippy::expect_used)]
+static PATH_NAME_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\{(?<name>\S+):(.*)\}").expect("path name regex"));
+
+fn sanitize_patterned_path_parameter(path: &str) -> String {
+  let param_names: Vec<&str> = path.split('/').collect();
+  let mut path_parts = vec![];
+  for p in &param_names {
+    path_parts.push(PATH_NAME_REGEX.replace(p, "{$name}").to_string());
+  }
+  path_parts.join("/")
+}
+
 #[allow(clippy::expect_used)]
 static PATH_RESOURCE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"/(.*?)/\{(.*?)\}").expect("path template regex"));
 
diff --git a/apistos/src/internal/actix/utils/mod.rs b/apistos/src/internal/actix/utils/mod.rs
index c04b477..4122bbc 100644
--- a/apistos/src/internal/actix/utils/mod.rs
+++ b/apistos/src/internal/actix/utils/mod.rs
@@ -1,6 +1,8 @@
-use apistos_models::paths::{Operation, ParameterIn};
+use apistos_models::paths::{Operation, ParameterDefinition, ParameterIn};
+use apistos_models::reference_or::ReferenceOr;
 use once_cell::sync::Lazy;
 use regex::{Captures, Regex};
+use schemars::schema::{Schema, SchemaObject, StringValidation};
 use std::borrow::Cow;
 
 /// Regex that can be used to fetch templated path parameters.
@@ -27,8 +29,17 @@ impl OperationUpdater for Operation {
       .filter(|p| p._in == ParameterIn::Path)
     {
       if let Some(n) = param_names.pop() {
-        if let Some((name, _pattern)) = n.split_once(':') {
+        if let Some((name, pattern)) = n.split_once(':') {
           param.name = name.to_string();
+          param.definition = Some(ParameterDefinition::Schema(ReferenceOr::Object(Schema::Object(
+            SchemaObject {
+              string: Some(Box::new(StringValidation {
+                pattern: Some(pattern.to_string()),
+                ..Default::default()
+              })),
+              ..Default::default()
+            },
+          ))))
         } else {
           param.name = n;
         }
@@ -41,9 +52,12 @@ impl OperationUpdater for Operation {
 
 #[cfg(test)]
 mod test {
+  #![allow(clippy::panic)]
+
   use crate::internal::actix::utils::OperationUpdater;
-  use apistos_models::paths::{Operation, Parameter, ParameterIn};
+  use apistos_models::paths::{Operation, Parameter, ParameterDefinition, ParameterIn};
   use apistos_models::reference_or::ReferenceOr;
+  use apistos_models::Schema;
 
   #[test]
   fn simple_path_parameter_name_replacement() {
@@ -102,4 +116,40 @@ mod test {
     assert_eq!(first_parameter_name, "plop_id".to_string());
     assert_eq!(second_parameter_name, "clap_id".to_string());
   }
+
+  #[test]
+  fn regex_path_parameter_name_replacement() {
+    let mut operation = Operation {
+      parameters: vec![ReferenceOr::Object(Parameter {
+        name: "".to_string(),
+        _in: ParameterIn::Path,
+        ..Default::default()
+      })],
+      ..Default::default()
+    };
+
+    operation.update_path_parameter_name_from_path("/test/{plop_id:.+}/plop");
+
+    let first_parameter = operation.parameters.first().and_then(|p| p.clone().get_object());
+
+    let first_parameter_name = first_parameter.clone().map(|p| p.name.clone()).unwrap_or_default();
+    assert_eq!(first_parameter_name, "plop_id".to_string());
+
+    if let Some(p) = first_parameter {
+      let def = p.definition.clone().expect("missing parameter definition");
+      match def {
+        ParameterDefinition::Schema(sch) => match sch {
+          ReferenceOr::Object(obj) => match obj {
+            Schema::Bool(_) => panic!("expected schema object"),
+            Schema::Object(obj) => {
+              let str_obj = obj.string.expect("should be a string schema");
+              assert_eq!(str_obj.pattern, Some(".+".to_string()));
+            }
+          },
+          ReferenceOr::Reference { .. } => panic!("expected schema object"),
+        },
+        ParameterDefinition::Content(_) => panic!("expected schema"),
+      }
+    }
+  }
 }
diff --git a/examples/petstore/src/api/routes.rs b/examples/petstore/src/api/routes.rs
index c123f81..4d68047 100644
--- a/examples/petstore/src/api/routes.rs
+++ b/examples/petstore/src/api/routes.rs
@@ -16,7 +16,7 @@ pub(crate) fn routes() -> Scope {
       .service(resource("/findByStatus").route(get().to(find_by_status)))
       .service(resource("/findByTags").route(get().to(#[allow(deprecated)] find_by_tags)))
       .service(
-        scope("/{petId}").service(
+        scope("/{petId:.+}").service(
           resource("")
             .route(get().to(get_pet))
             .route(post().to(update_pet_with_form))


Test patch:
diff --git a/apistos/tests/operations.rs b/apistos/tests/operations.rs
index be7b724..7ac22b8 100644
--- a/apistos/tests/operations.rs
+++ b/apistos/tests/operations.rs
@@ -263,6 +263,80 @@ async fn operation_generics() {
   assert!(operation.parameters.is_empty());
 }
 
+#[actix_web::test]
+async fn operation_pattern_path() {
+  #[derive(Serialize, Deserialize, Debug, Clone, ApiErrorComponent)]
+  #[openapi_error(status(code = 405, description = "Invalid input"))]
+  pub(crate) enum ErrorResponse {
+    MethodNotAllowed(String),
+  }
+
+  impl Display for ErrorResponse {
+    fn fmt(&self, _f: &mut Formatter<'_>) -> std::fmt::Result {
+      panic!()
+    }
+  }
+
+  impl ResponseError for ErrorResponse {
+    fn status_code(&self) -> StatusCode {
+      panic!()
+    }
+  }
+
+  #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, ApiComponent)]
+  struct Test {
+    id_number: u32,
+    id_string: String,
+  }
+
+  #[api_operation(tag = "pet", skip_args = "_params")]
+  pub(crate) async fn test<T, U>(_params: Path<(u32, String)>, _test: Json<Test>) -> Result<Json<Test>, ErrorResponse> {
+    panic!()
+  }
+
+  let openapi_path = "/test.json";
+  let operation_path = "/test/{plop_id}/{clap_name}";
+
+  let info = Info {
+    title: "A well documented API".to_string(),
+    description: Some("Really well document I mean it".to_string()),
+    terms_of_service: Some("https://terms.com".to_string()),
+    ..Default::default()
+  };
+  let tags = vec![Tag {
+    name: "A super tag".to_owned(),
+    ..Default::default()
+  }];
+  let spec = Spec {
+    info: info.clone(),
+    tags: tags.clone(),
+    ..Default::default()
+  };
+  let app = App::new()
+    .document(spec)
+    .service(scope("test").service(resource("/{plop_id:.+}/{clap_name}").route(get().to(test::<String, String>))))
+    .build(openapi_path);
+  let app = init_service(app).await;
+
+  let req = TestRequest::get().uri(openapi_path).to_request();
+  let resp = call_service(&app, req).await;
+  assert!(resp.status().is_success());
+
+  let body: OpenApi = try_read_body_json(resp).await.expect("Unable to read body");
+  let paths = body.paths.paths;
+
+  let operation = paths.get(&operation_path.to_string()).cloned();
+  assert!(operation.is_some());
+  let operation = operation
+    .unwrap_or_default()
+    .operations
+    .get(&OperationType::Get)
+    .cloned()
+    .unwrap_or_default();
+  assert!(operation.request_body.is_some());
+  assert!(operation.parameters.is_empty());
+}
+
 // Imports bellow aim at making clippy happy. Those dependencies are necessary for integration-test.
 use actix_service as _;
 use actix_web_lab as _;