Repository: poem-web/poem (Rust)

Issue:
openapi: method with same URL and different path parameters name result in 405
## Expected Behavior
Both endpoints should return 200 OK

## Actual Behavior
When having two endpoints on the same URL with different path parameters names result in one of them returning 200 OK and the other one 405 Method Not Allowed. It seems that the order of the functions does not matter, the GET method always gets the 405.
This issue is related to #429 but since that issue is closed and I cannot reopen it I'am opening a new one.

## Steps to Reproduce the Problem
I have created a minimal reproduction of this issue repository: https://github.com/robko23/poem-openapi-routing-issue/tree/master

## Specifications

- Version: poem-openapi@5.1.5 poem@3.1.6
- Platform: GNU/Linux 6.12
- Subsystem: N/A


Discussion:
So as I was investigating further, I found out that the issue #429 was fixed by 78fb1123822acf2903fc795879c4a83c78fb448f. (change `route_table` from `HashMap<String, HashMap<Method, ...>>` to `HashMap<Method, HashMap<String,...>>`)
Then there was another issue ( #489 ) which was resolved by 9876172c44a32d0c5e353c4f5eb646a5d2870718. (change `route_table` back to `HashMap<String, HashMap<Method, ...>>`
However these issues seem to be mutually exclusive and the latter commit reverted the fix by the former commit.
Kinda tricky situation. Any ideas how to solve this?
So I have an idea that this could be resolved by renaming the path to normalize param names.
eg. `/hello/:param` -> `/hello/:param0`, `/hello/:another_param` -> `/hello/:param0`.

This would create same entry in the routing table and it could resolve the issue. It could be "injecting" `#[oai(name = "param{i}")]` in the function signature.

I can look into this on the weekend and send PR to resolve this, but a small guidance would be appreciated.

I tried inserting `format!("param{i}")` here, but that did not work.
https://github.com/poem-web/poem/blob/658f7de3106b96941449711eb67fcdc157f1a16e/poem-openapi-derive/src/utils.rs#L127-L132

Then I tried changing this
https://github.com/poem-web/poem/blob/658f7de3106b96941449711eb67fcdc157f1a16e/poem-openapi-derive/src/api.rs#L278-L282

like this
```rust
let param_name = format!("param{i}");
```
but that didn't work either.
However I think that I'm on the right track.
I just need to rename the entry in the routing table and then change this
https://github.com/poem-web/poem/blob/658f7de3106b96941449711eb67fcdc157f1a16e/poem-openapi-derive/src/api.rs#L351-L355
to extract from the `param{i}` while keeping the openapi param name in meta and the variable name in method signature



Patch:
diff --git a/poem-openapi-derive/src/api.rs b/poem-openapi-derive/src/api.rs
index 33bcd43b8a..e987751f14 100644
--- a/poem-openapi-derive/src/api.rs
+++ b/poem-openapi-derive/src/api.rs
@@ -241,6 +241,8 @@ fn generate_operation(
     let mut params_meta = Vec::new();
     let mut security = Vec::new();
 
+    let mut path_param_count = 0;
+
     for i in 1..item_method.sig.inputs.len() {
         let arg = &mut item_method.sig.inputs[i];
         let (arg_ident, mut arg_ty, operation_param, param_description) = match arg {
@@ -272,6 +274,12 @@ fn generate_operation(
                 return Err(Error::new_spanned(item_method, "Invalid method definition.").into());
             }
         };
+        let is_path = match &*arg_ty {
+            syn::Type::Path(syn::TypePath { qself: _, path }) => {
+                path.segments.iter().any(|v| v.ident.to_string() == "Path")
+            }
+            _ => false,
+        };
 
         RemoveLifetime.visit_type_mut(&mut arg_ty);
 
@@ -280,6 +288,13 @@ fn generate_operation(
             .name
             .clone()
             .unwrap_or_else(|| arg_ident.unraw().to_string());
+        let extract_param_name = is_path
+            .then(|| {
+                let n = format!("param{path_param_count}");
+                path_param_count += 1;
+                n
+            })
+            .unwrap_or_else(|| param_name.clone());
         use_args.push(pname.clone());
 
         if !hidden {
@@ -349,7 +364,7 @@ fn generate_operation(
 
         parse_args.push(quote! {
             let mut param_opts = #crate_name::ExtractParamOptions {
-                name: #param_name,
+                name: #extract_param_name,
                 default_value: #default_value,
                 example_value: #example_value,
                 explode: #explode,
diff --git a/poem-openapi-derive/src/utils.rs b/poem-openapi-derive/src/utils.rs
index 959ccc1f97..2648da08d5 100644
--- a/poem-openapi-derive/src/utils.rs
+++ b/poem-openapi-derive/src/utils.rs
@@ -117,6 +117,7 @@ pub(crate) fn convert_oai_path(path: &SpannedValue<String>) -> Result<(String, S
     let mut oai_path = String::new();
     let mut new_path = String::new();
     let mut vars = HashSet::new();
+    let mut param_count = 0;
 
     for s in path.split('/') {
         if s.is_empty() {
@@ -129,7 +130,8 @@ pub(crate) fn convert_oai_path(path: &SpannedValue<String>) -> Result<(String, S
             oai_path.push('}');
 
             new_path.push_str("/:");
-            new_path.push_str(var);
+            new_path.push_str(&format!("param{param_count}"));
+            param_count += 1;
 
             if !vars.insert(var) {
                 return Err(Error::new(


Test patch:
diff --git a/poem-openapi/tests/api.rs b/poem-openapi/tests/api.rs
index 85c6e7c025..02e4ffef05 100644
--- a/poem-openapi/tests/api.rs
+++ b/poem-openapi/tests/api.rs
@@ -547,6 +547,43 @@ async fn poem_extract() {
     client.get("/test3/7").send().await.assert_status_is_ok();
 }
 
+#[tokio::test]
+async fn issue_948() {
+    struct Api;
+    #[OpenApi]
+    impl Api {
+        #[oai(path = "/hello/:another_param", method = "post")]
+        async fn another_param_handler(
+            &self,
+            Path(another_param): Path<String>,
+        ) -> PlainText<String> {
+            PlainText(format!("POST /hello/{another_param}"))
+        }
+
+        #[oai(path = "/hello/:param", method = "get")]
+        async fn param_handler(&self, Path(param): Path<String>) -> PlainText<String> {
+            PlainText(format!("GET /hello/{param}"))
+        }
+    }
+
+    let meta = Api::meta().remove(0);
+    assert_eq!(meta.paths[0].path, "/hello/{another_param}");
+    assert_eq!(meta.paths[0].operations[0].method, Method::POST);
+
+    assert_eq!(meta.paths[1].path, "/hello/{param}");
+    assert_eq!(meta.paths[1].operations[0].method, Method::GET);
+
+    let ep = OpenApiService::new(Api, "test", "1.0");
+    let client = TestClient::new(ep);
+    let res = client.get("/hello/something").send().await;
+    res.assert_status_is_ok();
+    res.assert_text("GET /hello/something").await;
+
+    let res = client.post("/hello/another_something").send().await;
+    res.assert_status_is_ok();
+    res.assert_text("POST /hello/another_something").await;
+}
+
 #[tokio::test]
 async fn returning_borrowed_value() {
     struct Api {