Repository: localstack/localstack

Issue:
bug: Localstack does not properly handle apigateway proxy resources
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

I have a number of lambda functions behind an API Gateway, each responsible for serving a small API. My stack is defined in CDK and deployed to localstack using cdklocal.

For each of the services mounted on the gateway, I have 2 integration defined. One for the root of the resource and another proxing any none-root requests into the same lambda. 

My resources look like this:


```sh
> awslocal apigateway get-rest-apis
{
    "items": [
        {
            "id": "lgvd3zqa8b",
            "name": "TracktileBackendApiLocal",
            "description": "",
            "createdDate": "2021-12-14T18:16:08-04:00",
            "version": "V1",
            "binaryMediaTypes": [],
            "apiKeySource": "HEADER",
            "endpointConfiguration": {
                "types": [
                    "EDGE"
                ]
            },
            "tags": {},
            "disableExecuteApiEndpoint": false
        }
    ]
}
> awslocal apigateway get-resources --rest-api-id lgvd3zqa8b
{
    "items": [
        {
            "id": "f2zdoj6dmi",
            "path": "/",
            "resourceMethods": {
                "ANY": {
                    "httpMethod": "ANY",
                    "authorizationType": "NONE",
                    "apiKeyRequired": false,
                    "methodIntegration": {
                        "type": "AWS_PROXY",
                        "httpMethod": "POST",
                        "uri": "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:000000000000:function:TracktileBackendLocal-CoreapiHandler25B88C23-ca16ca78/invocations",
                        "requestParameters": {},
                        "passthroughBehavior": "WHEN_NO_MATCH",
                        "cacheNamespace": "fecc94d8",
                        "cacheKeyParameters": []
                    }
                }
            }
        },
        {
            "id": "wetl5415x6",
            "parentId": "f2zdoj6dmi",
            "pathPart": "{proxy+}",
            "path": "/{proxy+}",
            "resourceMethods": {
                "ANY": {
                    "httpMethod": "ANY",
                    "authorizationType": "NONE",
                    "apiKeyRequired": false,
                    "methodIntegration": {
                        "type": "AWS_PROXY",
                        "httpMethod": "POST",
                        "uri": "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:000000000000:function:TracktileBackendLocal-CoreapiHandler25B88C23-ca16ca78/invocations",
                        "requestParameters": {},
                        "passthroughBehavior": "WHEN_NO_MATCH",
                        "cacheNamespace": "0da7cd80",
                        "cacheKeyParameters": []
                    }
                }
            }
        },
        {
            "id": "ivgn3c0vcz",
            "parentId": "f2zdoj6dmi",
            "pathPart": "flow",
            "path": "/flow",
            "resourceMethods": {
                "ANY": {
                    "httpMethod": "ANY",
                    "authorizationType": "NONE",
                    "apiKeyRequired": false,
                    "methodIntegration": {
                        "type": "AWS_PROXY",
                        "httpMethod": "POST",
                        "uri": "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:000000000000:function:TracktileBackendLocal-FlowapiHandler8606373F-4237e93b/invocations",
                        "requestParameters": {},
                        "passthroughBehavior": "WHEN_NO_MATCH",
                        "cacheNamespace": "a746819b",
                        "cacheKeyParameters": []
                    }
                }
            }
        },
        {
            "id": "plotkhzk1u",
            "parentId": "ivgn3c0vcz",
            "pathPart": "{proxy+}",
            "path": "/flow/{proxy+}",
            "resourceMethods": {
                "ANY": {
                    "httpMethod": "ANY",
                    "authorizationType": "NONE",
                    "apiKeyRequired": false,
                    "methodIntegration": {
                        "type": "AWS_PROXY",
                        "httpMethod": "POST",
                        "uri": "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:000000000000:function:TracktileBackendLocal-FlowapiHandler8606373F-4237e93b/invocations",
                        "requestParameters": {},
                        "passthroughBehavior": "WHEN_NO_MATCH",
                        "cacheNamespace": "d53a7f58",
                        "cacheKeyParameters": []
                    }
                }
            }
        },
        {
            "id": "scpfu9z3l4",
            "parentId": "f2zdoj6dmi",
            "pathPart": "formulas",
            "path": "/formulas",
            "resourceMethods": {
                "ANY": {
                    "httpMethod": "ANY",
                    "authorizationType": "NONE",
                    "apiKeyRequired": false,
                    "methodIntegration": {
                        "type": "AWS_PROXY",
                        "httpMethod": "POST",
                        "uri": "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:000000000000:function:TracktileBackendLocal-FormulaapiHandler4EC50B4-1db1cda1/invocations",
                        "requestParameters": {},
                        "passthroughBehavior": "WHEN_NO_MATCH",
                        "cacheNamespace": "2edb46e1",
                        "cacheKeyParameters": []
                    }
                }
            }
        },
        {
            "id": "ehhukzlulm",
            "parentId": "scpfu9z3l4",
            "pathPart": "{proxy+}",
            "path": "/formulas/{proxy+}",
            "resourceMethods": {
                "ANY": {
                    "httpMethod": "ANY",
                    "authorizationType": "NONE",
                    "apiKeyRequired": false,
                    "methodIntegration": {
                        "type": "AWS_PROXY",
                        "httpMethod": "POST",
                        "uri": "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:000000000000:function:TracktileBackendLocal-FormulaapiHandler4EC50B4-1db1cda1/invocations",
                        "requestParameters": {},
                        "passthroughBehavior": "WHEN_NO_MATCH",
                        "cacheNamespace": "8385d874",
                        "cacheKeyParameters": []
                    }
                }
            }
        },
        {
            "id": "g0d4khz6zr",
            "parentId": "f2zdoj6dmi",
            "pathPart": "orders",
            "path": "/orders",
            "resourceMethods": {
                "ANY": {
                    "httpMethod": "ANY",
                    "authorizationType": "NONE",
                    "apiKeyRequired": false,
                    "methodIntegration": {
                        "type": "AWS_PROXY",
                        "httpMethod": "POST",
                        "uri": "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:000000000000:function:TracktileBackendLocal-PurchaseOrderapiHandler4-1b5fc38d/invocations",
                        "requestParameters": {},
                        "passthroughBehavior": "WHEN_NO_MATCH",
                        "cacheNamespace": "aa1bb342",
                        "cacheKeyParameters": []
                    }
                }
            }
        },
        {
            "id": "p3n7jxk9p4",
            "parentId": "g0d4khz6zr",
            "pathPart": "{proxy+}",
            "path": "/orders/{proxy+}",
            "resourceMethods": {
                "ANY": {
                    "httpMethod": "ANY",
                    "authorizationType": "NONE",
                    "apiKeyRequired": false,
                    "methodIntegration": {
                        "type": "AWS_PROXY",
                        "httpMethod": "POST",
                        "uri": "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:000000000000:function:TracktileBackendLocal-PurchaseOrderapiHandler4-1b5fc38d/invocations",
                        "requestParameters": {},
                        "passthroughBehavior": "WHEN_NO_MATCH",
                        "cacheNamespace": "fa8379bd",
                        "cacheKeyParameters": []
                    }
                }
            }
        },
        {
            "id": "1o403r2xzn",
            "parentId": "f2zdoj6dmi",
            "pathPart": "dashboards",
            "path": "/dashboards",
            "resourceMethods": {
                "ANY": {
                    "httpMethod": "ANY",
                    "authorizationType": "NONE",
                    "apiKeyRequired": false,
                    "methodIntegration": {
                        "type": "AWS_PROXY",
                        "httpMethod": "POST",
                        "uri": "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:000000000000:function:TracktileBackendLocal-DashboardapiHandler67868-fea7c248/invocations",
                        "requestParameters": {},
                        "passthroughBehavior": "WHEN_NO_MATCH",
                        "cacheNamespace": "32beba39",
                        "cacheKeyParameters": []
                    }
                }
            }
        },
        {
            "id": "p4gz9qbom2",
            "parentId": "1o403r2xzn",
            "pathPart": "{proxy+}",
            "path": "/dashboards/{proxy+}",
            "resourceMethods": {
                "ANY": {
                    "httpMethod": "ANY",
                    "authorizationType": "NONE",
                    "apiKeyRequired": false,
                    "methodIntegration": {
                        "type": "AWS_PROXY",
                        "httpMethod": "POST",
                        "uri": "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:000000000000:function:TracktileBackendLocal-DashboardapiHandler67868-fea7c248/invocations",
                        "requestParameters": {},
                        "passthroughBehavior": "WHEN_NO_MATCH",
                        "cacheNamespace": "2b449313",
                        "cacheKeyParameters": []
                    }
                }
            }
        }
    ]
}
```

The following requests succeed against my gateway:
- `GET /`
- `GET /dashboards`
- `GET /flow`
- `GET /flow/{name}`

However, this path fails
- `GET /flow/{name}/positions`

With this error returned from the gateway:
```
{"Type": "User", "message": "Unable to find path /prod/flow/default/positions", "__type": "NotFoundException"}
```

For some reason this path fails. There is a path parameter in the middle of the path, but API Gateway is not aware of it, the entire path should be parsed and handled by my express application inside of the lambda.

As far as I can tell, localstack appears to throw this error when this function is unable to resolve the path to a resource: https://github.com/localstack/localstack/blob/7c4c7e611e412b36d62528fda61aa2f764ac94da/localstack/services/apigateway/apigateway_listener.py#L896

However, I don't know enough about API Gateway to try my hand at a PR.

### Expected Behavior

I expect the request to match the proxy resource and be handled by the correct lambda.

### How are you starting LocalStack?

With a docker-compose file

### Steps To Reproduce

- Deploy API Gateway resources matching above


### Environment

```markdown
Using `localstack/localstack:latest` as of the time of this issue.

# Environment
DATA_DIR=/tmp/localstack/data
EXTRA_CORS_ALLOWED_ORIGINS=http://localhost:3001
LAMBDA_EXECUTOR=docker-reuse
LAMBDA_REMOTE_DOCKER=false
LAMBDA_STAY_OPEN_MODE=1
LAMBDA_DOCKER_NETWORK=myapp_local
DYNAMODB_SHARE_DB=1
LS_LOG=trace
```


### Anything else?

_No response_


Discussion:

Resolving patch:
diff --git a/localstack/services/apigateway/helpers.py b/localstack/services/apigateway/helpers.py
--- a/localstack/services/apigateway/helpers.py
+++ b/localstack/services/apigateway/helpers.py
@@ -1,7 +1,7 @@
 import json
 import logging
 import re
-from typing import Any, Dict, List, Tuple
+from typing import Any, Dict, List, Optional, Tuple
 
 from botocore.utils import InvalidArnException
 from jsonpatch import apply_patch
@@ -936,7 +936,8 @@ def get_rest_api_paths(rest_api_id, region_name=None):
     resource_map = {}
     for resource in resources["items"]:
         path = resource.get("path")
-        # TODO: check if this is still required in the general case (can we rely on "path" being present?)
+        # TODO: check if this is still required in the general case (can we rely on "path" being
+        #  present?)
         path = path or aws_stack.get_apigateway_path_for_resource(
             rest_api_id, resource["id"], region_name=region_name
         )
@@ -944,36 +945,56 @@ def get_rest_api_paths(rest_api_id, region_name=None):
     return resource_map
 
 
-def get_resource_for_path(path: str, path_map: Dict[str, Dict]) -> Tuple[str, Dict]:
+# TODO: Extract this to a set of rules that have precedence and easy to test individually.
+#
+#  https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-method-settings
+#  -method-request.html
+#  https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-routes.html
+def get_resource_for_path(path: str, path_map: Dict[str, Dict]) -> Optional[Tuple[str, dict]]:
     matches = []
+    # creates a regex from the input path if there are parameters, e.g /foo/{bar}/baz -> /foo/[
+    # ^\]+/baz, otherwise is a direct match.
     for api_path, details in path_map.items():
-        api_path_regex = re.sub(r"\{[^\+]+\+\}", r"[^\?#]+", api_path)
-        api_path_regex = re.sub(r"\{[^\}]+\}", r"[^/]+", api_path_regex)
+        api_path_regex = re.sub(r"{[^+]+\+}", r"[^\?#]+", api_path)
+        api_path_regex = re.sub(r"{[^}]+}", r"[^/]+", api_path_regex)
         if re.match(r"^%s$" % api_path_regex, path):
             matches.append((api_path, details))
+
+    # if there are no matches, it's not worth to proceed, bail here!
     if not matches:
         return None
+
+    # so we have matches and perhaps more than one, e.g
+    # /{proxy+} and /api/{proxy+} for inputs like /api/foo/bar
+    # /foo/{param1}/baz and /foo/{param1}/{param2} for inputs like /for/bar/baz
     if len(matches) > 1:
-        # check if we have an exact match
+        # check if we have an exact match (exact matches take precedence)
         for match in matches:
             if match[0] == path:
                 return match
+            # not an exact match but parameters can fit in
             if path_matches_pattern(path, match[0]):
                 return match
-        raise Exception("Ambiguous API path %s - matches found: %s" % (path, matches))
+
+        # at this stage, we have more than one match but we have an eager example like
+        # /{proxy+} or /api/{proxy+}, so we pick the best match by sorting by length
+        sorted_matches = sorted(matches, key=lambda x: len(x[0]), reverse=True)
+        return sorted_matches[0]
     return matches[0]
 
 
 def path_matches_pattern(path, api_path):
     api_paths = api_path.split("/")
     paths = path.split("/")
-    reg_check = re.compile(r"\{(.*)\}")
-    results = []
+    reg_check = re.compile(r"{(.*)}")
     if len(api_paths) != len(paths):
         return False
-    for indx, part in enumerate(api_paths):
-        if reg_check.match(part) is None and part:
-            results.append(part == paths[indx])
+    results = [
+        part == paths[indx]
+        for indx, part in enumerate(api_paths)
+        if reg_check.match(part) is None and part
+    ]
+
     return len(results) > 0 and all(results)
 
 
@@ -1014,7 +1035,8 @@ def connect_api_gateway_to_sqs(gateway_name, stage_name, queue_arn, path, region
 
 
 def apply_json_patch_safe(subject, patch_operations, in_place=True, return_list=False):
-    """Apply JSONPatch operations, using some customizations for compatibility with API GW resources."""
+    """Apply JSONPatch operations, using some customizations for compatibility with API GW
+    resources."""
 
     results = []
     patch_operations = (


Test patch:
diff --git a/tests/unit/test_apigateway.py b/tests/unit/test_apigateway.py
--- a/tests/unit/test_apigateway.py
+++ b/tests/unit/test_apigateway.py
@@ -42,6 +42,11 @@ def test_path_matches(self):
         )
         self.assertEqual("/foo/{proxy+}", path)
 
+        path, details = apigateway_listener.get_resource_for_path(
+            "/foo/bar/baz", {"/{proxy+}": {}, "/foo/{proxy+}": {}}
+        )
+        self.assertEqual("/foo/{proxy+}", path)
+
         result = apigateway_listener.get_resource_for_path(
             "/foo/bar", {"/foo/bar1": {}, "/foo/bar2": {}}
         )
@@ -76,6 +81,10 @@ def test_path_matches(self):
         result = apigateway_listener.get_resource_for_path("/foo/bar/baz", path_args)
         self.assertEqual(None, result)
 
+        path_args = {"/foo/{param1}/baz": {}, "/foo/{param1}/{param2}": {}}
+        path, result = apigateway_listener.get_resource_for_path("/foo/bar/baz", path_args)
+        self.assertEqual("/foo/{param1}/baz", path)
+
     def test_apply_request_parameters(self):
         integration = {
             "type": "HTTP_PROXY",