diff --git a/localstack-core/localstack/aws/protocol/op_router.py b/localstack-core/localstack/aws/protocol/op_router.py index 126d417d94742..f4c5f1019aa02 100644 --- a/localstack-core/localstack/aws/protocol/op_router.py +++ b/localstack-core/localstack/aws/protocol/op_router.py @@ -8,7 +8,6 @@ from werkzeug.routing import Map, MapAdapter from localstack.aws.protocol.routing import ( - GreedyPathConverter, StrictMethodRule, path_param_regex, post_process_arg_name, @@ -16,6 +15,7 @@ ) from localstack.http import Request from localstack.http.request import get_raw_path +from localstack.http.router import GreedyPathConverter class _HttpOperation(NamedTuple): diff --git a/localstack-core/localstack/aws/protocol/routing.py b/localstack-core/localstack/aws/protocol/routing.py index 62df3dca73fcb..f793bd051ec27 100644 --- a/localstack-core/localstack/aws/protocol/routing.py +++ b/localstack-core/localstack/aws/protocol/routing.py @@ -1,7 +1,7 @@ import re from typing import AnyStr -from werkzeug.routing import PathConverter, Rule +from werkzeug.routing import Rule # Regex to find path parameters in requestUris of AWS service specs (f.e. /{param1}/{param2+}) path_param_regex = re.compile(r"({.+?})") @@ -12,20 +12,6 @@ _rule_replacement_table = str.maketrans(_rule_replacements) -class GreedyPathConverter(PathConverter): - """ - This converter makes sure that the path ``/mybucket//mykey`` can be matched to the pattern - ``<Bucket>/<path:Key>`` and will result in `Key` being `/mykey`. - """ - - regex = ".*?" - - part_isolating = False - """From the werkzeug docs: If a custom converter can match a forward slash, /, it should have the - attribute part_isolating set to False. This will ensure that rules using the custom converter are - correctly matched.""" - - class StrictMethodRule(Rule): """ Small extension to Werkzeug's Rule class which reverts unwanted assumptions made by Werkzeug. diff --git a/localstack-core/localstack/http/router.py b/localstack-core/localstack/http/router.py index 319e88c0e7ced..da3bcdfe043c0 100644 --- a/localstack-core/localstack/http/router.py +++ b/localstack-core/localstack/http/router.py @@ -14,12 +14,28 @@ route, ) from rolo.routing.router import Dispatcher, call_endpoint +from werkzeug.routing import PathConverter HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE") E = TypeVar("E") RequestArguments = Mapping[str, Any] + +class GreedyPathConverter(PathConverter): + """ + This converter makes sure that the path ``/mybucket//mykey`` can be matched to the pattern + ``<Bucket>/<path:Key>`` and will result in `Key` being `/mykey`. + """ + + regex = ".*?" + + part_isolating = False + """From the werkzeug docs: If a custom converter can match a forward slash, /, it should have the + attribute part_isolating set to False. This will ensure that rules using the custom converter are + correctly matched.""" + + __all__ = [ "RequestArguments", "HTTP_METHODS", @@ -32,4 +48,5 @@ "RuleAdapter", "WithHost", "RuleGroup", + "GreedyPathConverter", ] diff --git a/localstack-core/localstack/services/apigateway/helpers.py b/localstack-core/localstack/services/apigateway/helpers.py index db03da7f606e0..d1b1e6c8ae707 100644 --- a/localstack-core/localstack/services/apigateway/helpers.py +++ b/localstack-core/localstack/services/apigateway/helpers.py @@ -702,7 +702,8 @@ def extract_path_params(path: str, extracted_path: str) -> Dict[str, str]: def extract_query_string_params(path: str) -> Tuple[str, Dict[str, str]]: parsed_path = urlparse.urlparse(path) - path = parsed_path.path + if not path.startswith("//"): + path = parsed_path.path parsed_query_string_params = urlparse.parse_qs(parsed_path.query) query_string_params = {} diff --git a/localstack-core/localstack/services/apigateway/integration.py b/localstack-core/localstack/services/apigateway/integration.py index bd68d37ef8cd2..0c0868058d292 100644 --- a/localstack-core/localstack/services/apigateway/integration.py +++ b/localstack-core/localstack/services/apigateway/integration.py @@ -293,7 +293,7 @@ def construct_invocation_event( headers = canonicalize_headers(headers) return { - "path": path, + "path": "/" + path.lstrip("/"), "headers": headers, "multiValueHeaders": multi_value_dict_for_list(headers), "body": data, diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py index 311ac20069d87..020ee10ca8ce5 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py @@ -144,8 +144,7 @@ def create_context_variables(context: RestApiInvocationContext) -> ContextVariab userAgent=invocation_request["headers"].get("User-Agent"), userArn=None, ), - # TODO: check if we need the raw path? with forward slashes - path=f"/{context.stage}{invocation_request['path']}", + path=f"/{context.stage}{invocation_request['raw_path']}", protocol="HTTP/1.1", requestId=long_uid(), requestTime=timestamp(time=now, format=REQUEST_TIME_DATE_FORMAT), diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/resource_router.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/resource_router.py index f5b3064db8cf7..c957e24fb00bd 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/resource_router.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/resource_router.py @@ -8,12 +8,12 @@ from localstack.aws.api.apigateway import Resource from localstack.aws.protocol.routing import ( - GreedyPathConverter, path_param_regex, post_process_arg_name, transform_path_params_to_rule_vars, ) from localstack.http import Response +from localstack.http.router import GreedyPathConverter from localstack.services.apigateway.models import RestApiDeployment from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py index 4965e4eda66df..7eb7476d56c37 100644 --- a/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py @@ -107,7 +107,7 @@ def register_routes(self) -> None: strict_slashes=False, ), self.router.add( - path="/<stage>/<path:path>", + path="/<stage>/<greedy_path:path>", host=host_pattern, endpoint=self.handler, strict_slashes=True, @@ -119,7 +119,7 @@ def register_routes(self) -> None: defaults={"path": ""}, ), self.router.add( - path="/restapis/<api_id>/<stage>/_user_request_/<path:path>", + path="/restapis/<api_id>/<stage>/_user_request_/<greedy_path:path>", endpoint=self.handler, strict_slashes=True, ), diff --git a/localstack-core/localstack/services/apigateway/router_asf.py b/localstack-core/localstack/services/apigateway/router_asf.py index 44725eca593c3..4b3fa97a579f5 100644 --- a/localstack-core/localstack/services/apigateway/router_asf.py +++ b/localstack-core/localstack/services/apigateway/router_asf.py @@ -129,7 +129,7 @@ def register_routes(self) -> None: strict_slashes=False, ) self.router.add( - "/<stage>/<path:path>", + "/<stage>/<greedy_path:path>", host=host_pattern, endpoint=self.invoke_rest_api, strict_slashes=True, @@ -142,7 +142,7 @@ def register_routes(self) -> None: defaults={"path": ""}, ) self.router.add( - "/restapis/<api_id>/<stage>/_user_request_/<path:path>", + "/restapis/<api_id>/<stage>/_user_request_/<greedy_path:path>", endpoint=self.invoke_rest_api, strict_slashes=True, ) diff --git a/localstack-core/localstack/services/edge.py b/localstack-core/localstack/services/edge.py index 495525867383d..5c3ede66b65e5 100644 --- a/localstack-core/localstack/services/edge.py +++ b/localstack-core/localstack/services/edge.py @@ -12,6 +12,7 @@ ) from localstack.http import Router from localstack.http.dispatcher import Handler, handler_dispatcher +from localstack.http.router import GreedyPathConverter from localstack.utils.collections import split_list_by from localstack.utils.net import get_free_tcp_port from localstack.utils.run import is_root, run @@ -23,7 +24,9 @@ LOG = logging.getLogger(__name__) -ROUTER: Router[Handler] = Router(dispatcher=handler_dispatcher()) +ROUTER: Router[Handler] = Router( + dispatcher=handler_dispatcher(), converters={"greedy_path": GreedyPathConverter} +) """This special Router is part of the edge proxy. Use the router to inject custom handlers that are handled before the actual AWS service call is made.""" diff --git a/tests/aws/services/apigateway/test_apigateway_lambda.py b/tests/aws/services/apigateway/test_apigateway_lambda.py index 37751aaa75018..f2bb3df57fb00 100644 --- a/tests/aws/services/apigateway/test_apigateway_lambda.py +++ b/tests/aws/services/apigateway/test_apigateway_lambda.py @@ -181,6 +181,26 @@ def invoke_api(url): response_trailing_slash = retry(invoke_api, sleep=2, retries=10, url=f"{invocation_url}/") snapshot.match("invocation-payload-with-trailing-slash", response_trailing_slash.json()) + # invoke rest api with double slash in proxy param + response_double_slash = retry( + invoke_api, sleep=2, retries=10, url=f"{invocation_url}//test-path" + ) + snapshot.match("invocation-payload-with-double-slash", response_double_slash.json()) + + # invoke rest api with prepended slash to the stage (//<stage>/<path>) + double_slash_before_stage = invocation_url.replace(f"/{stage_name}/", f"//{stage_name}/") + response_prepend_slash = retry(invoke_api, sleep=2, retries=10, url=double_slash_before_stage) + snapshot.match( + "invocation-payload-with-prepended-slash-to-stage", response_prepend_slash.json() + ) + + # invoke rest api with prepended slash + slash_between_stage_and_path = invocation_url.replace("/test-path", "//test-path") + response_prepend_slash = retry( + invoke_api, sleep=2, retries=10, url=slash_between_stage_and_path + ) + snapshot.match("invocation-payload-with-prepended-slash", response_prepend_slash.json()) + response_no_trailing_slash = retry( invoke_api, sleep=2, retries=10, url=f"{invocation_url}?urlparam=test" ) diff --git a/tests/aws/services/apigateway/test_apigateway_lambda.snapshot.json b/tests/aws/services/apigateway/test_apigateway_lambda.snapshot.json index 8af3f800b0cf3..96817abc33fb8 100644 --- a/tests/aws/services/apigateway/test_apigateway_lambda.snapshot.json +++ b/tests/aws/services/apigateway/test_apigateway_lambda.snapshot.json @@ -1,6 +1,6 @@ { "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration": { - "recorded-date": "22-07-2024, 22:41:52", + "recorded-date": "25-07-2024, 20:18:13", "recorded-content": { "invocation-payload-without-trailing-slash": { "body": null, @@ -238,7 +238,7 @@ "resource": "/{proxy+}", "stageVariables": null }, - "invocation-payload-without-trailing-slash-and-query-params": { + "invocation-payload-with-double-slash": { "body": null, "headers": { "Accept": "*/*", @@ -316,6 +316,360 @@ "aValUE" ] }, + "multiValueQueryStringParameters": null, + "path": "/test-path//test-path", + "pathParameters": { + "proxy": "test-path//test-path" + }, + "queryStringParameters": null, + "requestContext": { + "accountId": "111111111111", + "apiId": "<api-id>", + "deploymentId": "<deployment-id:1>", + "domainName": "<host:1>", + "domainPrefix": "<api-id>", + "extendedRequestId": "<extended-request-id:3>", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "<source-ip:1>", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/test/test-path//test-path", + "protocol": "HTTP/1.1", + "requestId": "<uuid:3>", + "requestTime": "<request-time>", + "requestTimeEpoch": "<request-time-epoch>", + "resourceId": "<resource-id:1>", + "resourcePath": "/{proxy+}", + "stage": "test" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-with-prepended-slash-to-stage": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "<cloudfront-asn:1>", + "CloudFront-Viewer-Country": "<cloudfront-country:1>", + "Host": "<host:1>", + "User-Agent": "python-requests/testing", + "Via": "<via:1>", + "X-Amz-Cf-Id": "<cf-id:4>", + "X-Amzn-Trace-Id": "<trace-id:4>", + "X-Forwarded-For": "<X-Forwarded-For>", + "X-Forwarded-Port": "<X-Forwarded-Port>", + "X-Forwarded-Proto": "<X-Forwarded-Proto>", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "<cloudfront-asn:1>" + ], + "CloudFront-Viewer-Country": [ + "<cloudfront-country:1>" + ], + "Host": [ + "<host:1>" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "<via:1>" + ], + "X-Amz-Cf-Id": [ + "<cf-id:4>" + ], + "X-Amzn-Trace-Id": [ + "<trace-id:4>" + ], + "X-Forwarded-For": "<X-Forwarded-For>", + "X-Forwarded-Port": "<X-Forwarded-Port>", + "X-Forwarded-Proto": "<X-Forwarded-Proto>", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": null, + "path": "/test-path", + "pathParameters": { + "proxy": "test-path" + }, + "queryStringParameters": null, + "requestContext": { + "accountId": "111111111111", + "apiId": "<api-id>", + "deploymentId": "<deployment-id:1>", + "domainName": "<host:1>", + "domainPrefix": "<api-id>", + "extendedRequestId": "<extended-request-id:4>", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "<source-ip:1>", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/test/test-path", + "protocol": "HTTP/1.1", + "requestId": "<uuid:4>", + "requestTime": "<request-time>", + "requestTimeEpoch": "<request-time-epoch>", + "resourceId": "<resource-id:1>", + "resourcePath": "/{proxy+}", + "stage": "test" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-with-prepended-slash": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "<cloudfront-asn:1>", + "CloudFront-Viewer-Country": "<cloudfront-country:1>", + "Host": "<host:1>", + "User-Agent": "python-requests/testing", + "Via": "<via:4>", + "X-Amz-Cf-Id": "<cf-id:5>", + "X-Amzn-Trace-Id": "<trace-id:5>", + "X-Forwarded-For": "<X-Forwarded-For>", + "X-Forwarded-Port": "<X-Forwarded-Port>", + "X-Forwarded-Proto": "<X-Forwarded-Proto>", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "<cloudfront-asn:1>" + ], + "CloudFront-Viewer-Country": [ + "<cloudfront-country:1>" + ], + "Host": [ + "<host:1>" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "<via:4>" + ], + "X-Amz-Cf-Id": [ + "<cf-id:5>" + ], + "X-Amzn-Trace-Id": [ + "<trace-id:5>" + ], + "X-Forwarded-For": "<X-Forwarded-For>", + "X-Forwarded-Port": "<X-Forwarded-Port>", + "X-Forwarded-Proto": "<X-Forwarded-Proto>", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": null, + "path": "/test-path", + "pathParameters": { + "proxy": "test-path" + }, + "queryStringParameters": null, + "requestContext": { + "accountId": "111111111111", + "apiId": "<api-id>", + "deploymentId": "<deployment-id:1>", + "domainName": "<host:1>", + "domainPrefix": "<api-id>", + "extendedRequestId": "<extended-request-id:5>", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "<source-ip:1>", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/test//test-path", + "protocol": "HTTP/1.1", + "requestId": "<uuid:5>", + "requestTime": "<request-time>", + "requestTimeEpoch": "<request-time-epoch>", + "resourceId": "<resource-id:1>", + "resourcePath": "/{proxy+}", + "stage": "test" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-without-trailing-slash-and-query-params": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "<cloudfront-asn:1>", + "CloudFront-Viewer-Country": "<cloudfront-country:1>", + "Host": "<host:1>", + "User-Agent": "python-requests/testing", + "Via": "<via:5>", + "X-Amz-Cf-Id": "<cf-id:6>", + "X-Amzn-Trace-Id": "<trace-id:6>", + "X-Forwarded-For": "<X-Forwarded-For>", + "X-Forwarded-Port": "<X-Forwarded-Port>", + "X-Forwarded-Proto": "<X-Forwarded-Proto>", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "<cloudfront-asn:1>" + ], + "CloudFront-Viewer-Country": [ + "<cloudfront-country:1>" + ], + "Host": [ + "<host:1>" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "<via:5>" + ], + "X-Amz-Cf-Id": [ + "<cf-id:6>" + ], + "X-Amzn-Trace-Id": [ + "<trace-id:6>" + ], + "X-Forwarded-For": "<X-Forwarded-For>", + "X-Forwarded-Port": "<X-Forwarded-Port>", + "X-Forwarded-Proto": "<X-Forwarded-Proto>", + "tEsT-HEADeR": [ + "aValUE" + ] + }, "multiValueQueryStringParameters": { "urlparam": [ "test" @@ -334,7 +688,7 @@ "deploymentId": "<deployment-id:1>", "domainName": "<host:1>", "domainPrefix": "<api-id>", - "extendedRequestId": "<extended-request-id:3>", + "extendedRequestId": "<extended-request-id:6>", "httpMethod": "GET", "identity": { "accessKey": null, @@ -352,7 +706,7 @@ }, "path": "/test/test-path", "protocol": "HTTP/1.1", - "requestId": "<uuid:3>", + "requestId": "<uuid:6>", "requestTime": "<request-time>", "requestTimeEpoch": "<request-time-epoch>", "resourceId": "<resource-id:1>", @@ -377,9 +731,9 @@ "CloudFront-Viewer-Country": "<cloudfront-country:1>", "Host": "<host:1>", "User-Agent": "python-requests/testing", - "Via": "<via:4>", - "X-Amz-Cf-Id": "<cf-id:4>", - "X-Amzn-Trace-Id": "<trace-id:4>", + "Via": "<via:6>", + "X-Amz-Cf-Id": "<cf-id:7>", + "X-Amzn-Trace-Id": "<trace-id:7>", "X-Forwarded-For": "<X-Forwarded-For>", "X-Forwarded-Port": "<X-Forwarded-Port>", "X-Forwarded-Proto": "<X-Forwarded-Proto>", @@ -425,13 +779,13 @@ "python-requests/testing" ], "Via": [ - "<via:4>" + "<via:6>" ], "X-Amz-Cf-Id": [ - "<cf-id:4>" + "<cf-id:7>" ], "X-Amzn-Trace-Id": [ - "<trace-id:4>" + "<trace-id:7>" ], "X-Forwarded-For": "<X-Forwarded-For>", "X-Forwarded-Port": "<X-Forwarded-Port>", @@ -458,7 +812,7 @@ "deploymentId": "<deployment-id:1>", "domainName": "<host:1>", "domainPrefix": "<api-id>", - "extendedRequestId": "<extended-request-id:4>", + "extendedRequestId": "<extended-request-id:7>", "httpMethod": "GET", "identity": { "accessKey": null, @@ -476,7 +830,7 @@ }, "path": "/test/test-path/", "protocol": "HTTP/1.1", - "requestId": "<uuid:4>", + "requestId": "<uuid:7>", "requestTime": "<request-time>", "requestTimeEpoch": "<request-time-epoch>", "resourceId": "<resource-id:1>", @@ -501,9 +855,9 @@ "CloudFront-Viewer-Country": "<cloudfront-country:1>", "Host": "<host:1>", "User-Agent": "python-requests/testing", - "Via": "<via:3>", - "X-Amz-Cf-Id": "<cf-id:5>", - "X-Amzn-Trace-Id": "<trace-id:5>", + "Via": "<via:7>", + "X-Amz-Cf-Id": "<cf-id:8>", + "X-Amzn-Trace-Id": "<trace-id:8>", "X-Forwarded-For": "<X-Forwarded-For>", "X-Forwarded-Port": "<X-Forwarded-Port>", "X-Forwarded-Proto": "<X-Forwarded-Proto>", @@ -549,13 +903,13 @@ "python-requests/testing" ], "Via": [ - "<via:3>" + "<via:7>" ], "X-Amz-Cf-Id": [ - "<cf-id:5>" + "<cf-id:8>" ], "X-Amzn-Trace-Id": [ - "<trace-id:5>" + "<trace-id:8>" ], "X-Forwarded-For": "<X-Forwarded-For>", "X-Forwarded-Port": "<X-Forwarded-Port>", @@ -576,7 +930,7 @@ "deploymentId": "<deployment-id:1>", "domainName": "<host:1>", "domainPrefix": "<api-id>", - "extendedRequestId": "<extended-request-id:5>", + "extendedRequestId": "<extended-request-id:8>", "httpMethod": "GET", "identity": { "accessKey": null, @@ -594,7 +948,7 @@ }, "path": "/test/test-path/api/user/test%2Balias@gmail.com/plus/test+alias@gmail.com", "protocol": "HTTP/1.1", - "requestId": "<uuid:5>", + "requestId": "<uuid:8>", "requestTime": "<request-time>", "requestTimeEpoch": "<request-time-epoch>", "resourceId": "<resource-id:1>", @@ -619,9 +973,9 @@ "CloudFront-Viewer-Country": "<cloudfront-country:1>", "Host": "<host:1>", "User-Agent": "python-requests/testing", - "Via": "<via:5>", - "X-Amz-Cf-Id": "<cf-id:6>", - "X-Amzn-Trace-Id": "<trace-id:6>", + "Via": "<via:8>", + "X-Amz-Cf-Id": "<cf-id:9>", + "X-Amzn-Trace-Id": "<trace-id:9>", "X-Forwarded-For": "<X-Forwarded-For>", "X-Forwarded-Port": "<X-Forwarded-Port>", "X-Forwarded-Proto": "<X-Forwarded-Proto>", @@ -667,13 +1021,13 @@ "python-requests/testing" ], "Via": [ - "<via:5>" + "<via:8>" ], "X-Amz-Cf-Id": [ - "<cf-id:6>" + "<cf-id:9>" ], "X-Amzn-Trace-Id": [ - "<trace-id:6>" + "<trace-id:9>" ], "X-Forwarded-For": "<X-Forwarded-For>", "X-Forwarded-Port": "<X-Forwarded-Port>", @@ -720,7 +1074,7 @@ "deploymentId": "<deployment-id:1>", "domainName": "<host:1>", "domainPrefix": "<api-id>", - "extendedRequestId": "<extended-request-id:6>", + "extendedRequestId": "<extended-request-id:9>", "httpMethod": "GET", "identity": { "accessKey": null, @@ -738,7 +1092,7 @@ }, "path": "/test/test-path/api", "protocol": "HTTP/1.1", - "requestId": "<uuid:6>", + "requestId": "<uuid:9>", "requestTime": "<request-time>", "requestTimeEpoch": "<request-time-epoch>", "resourceId": "<resource-id:1>", @@ -766,9 +1120,9 @@ "Content-Type": "application/json;charset=utf-8", "Host": "<host:1>", "User-Agent": "python-requests/testing", - "Via": "<via:5>", - "X-Amz-Cf-Id": "<cf-id:7>", - "X-Amzn-Trace-Id": "<trace-id:7>", + "Via": "<via:8>", + "X-Amz-Cf-Id": "<cf-id:10>", + "X-Amzn-Trace-Id": "<trace-id:10>", "X-Forwarded-For": "<X-Forwarded-For>", "X-Forwarded-Port": "<X-Forwarded-Port>", "X-Forwarded-Proto": "<X-Forwarded-Proto>" @@ -816,13 +1170,13 @@ "python-requests/testing" ], "Via": [ - "<via:5>" + "<via:8>" ], "X-Amz-Cf-Id": [ - "<cf-id:7>" + "<cf-id:10>" ], "X-Amzn-Trace-Id": [ - "<trace-id:7>" + "<trace-id:10>" ], "X-Forwarded-For": "<X-Forwarded-For>", "X-Forwarded-Port": "<X-Forwarded-Port>", @@ -853,7 +1207,7 @@ "deploymentId": "<deployment-id:1>", "domainName": "<host:1>", "domainPrefix": "<api-id>", - "extendedRequestId": "<extended-request-id:7>", + "extendedRequestId": "<extended-request-id:10>", "httpMethod": "POST", "identity": { "accessKey": null, @@ -871,7 +1225,7 @@ }, "path": "/test/test-path", "protocol": "HTTP/1.1", - "requestId": "<uuid:7>", + "requestId": "<uuid:10>", "requestTime": "<request-time>", "requestTimeEpoch": "<request-time-epoch>", "resourceId": "<resource-id:1>", diff --git a/tests/aws/services/apigateway/test_apigateway_lambda.validation.json b/tests/aws/services/apigateway/test_apigateway_lambda.validation.json index 1bfd60ef179b1..f2b75c8644081 100644 --- a/tests/aws/services/apigateway/test_apigateway_lambda.validation.json +++ b/tests/aws/services/apigateway/test_apigateway_lambda.validation.json @@ -9,7 +9,7 @@ "last_validated_date": "2023-05-31T21:09:38+00:00" }, "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration": { - "last_validated_date": "2024-07-22T22:41:52+00:00" + "last_validated_date": "2024-07-25T20:18:13+00:00" }, "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration_non_post_method": { "last_validated_date": "2024-07-10T15:43:36+00:00" diff --git a/tests/unit/aws/protocol/test_op_router.py b/tests/unit/aws/protocol/test_op_router.py index d010ba83df515..e42db6fcc512c 100644 --- a/tests/unit/aws/protocol/test_op_router.py +++ b/tests/unit/aws/protocol/test_op_router.py @@ -2,9 +2,10 @@ from werkzeug.exceptions import NotFound from werkzeug.routing import Map, Rule -from localstack.aws.protocol.op_router import GreedyPathConverter, RestServiceOperationRouter +from localstack.aws.protocol.op_router import RestServiceOperationRouter from localstack.aws.spec import list_services, load_service from localstack.http import Request +from localstack.http.router import GreedyPathConverter def _collect_services(): diff --git a/tests/unit/http_/test_router.py b/tests/unit/http_/test_router.py index e3c132b9004f3..149cdba1ff005 100644 --- a/tests/unit/http_/test_router.py +++ b/tests/unit/http_/test_router.py @@ -8,7 +8,14 @@ from werkzeug.routing import RequestRedirect, Submount from localstack.http import Request, Response, Router -from localstack.http.router import E, RequestArguments, RuleAdapter, WithHost, route +from localstack.http.router import ( + E, + GreedyPathConverter, + RequestArguments, + RuleAdapter, + WithHost, + route, +) from localstack.utils.common import get_free_tcp_port @@ -288,6 +295,56 @@ def test_path_converter_and_greedy_regex_in_host(self): "port": None, } + def test_greedy_path_converter(self): + router = Router(converters={"greedy_path": GreedyPathConverter}) + router.add(path="/<greedy_path:path>", endpoint=echo_params_json) + + assert router.dispatch(Request(path="/my")).json == {"path": "my"} + assert router.dispatch(Request(path="/my/")).json == {"path": "my/"} + assert router.dispatch(Request(path="/my//path")).json == {"path": "my//path"} + assert router.dispatch(Request(path="/my//path/")).json == {"path": "my//path/"} + assert router.dispatch(Request(path="/my/path foobar")).json == {"path": "my/path foobar"} + assert router.dispatch(Request(path="//foobar")).json == {"path": "foobar"} + assert router.dispatch(Request(path="//foobar/")).json == {"path": "foobar/"} + + def test_greedy_path_converter_with_args(self): + router = Router(converters={"greedy_path": GreedyPathConverter}) + router.add(path="/with-args/<some_id>/<greedy_path:path>", endpoint=echo_params_json) + + assert router.dispatch(Request(path="/with-args/123456/my")).json == { + "some_id": "123456", + "path": "my", + } + + # werkzeug no longer removes trailing slashes in matches + assert router.dispatch(Request(path="/with-args/123456/my/")).json == { + "some_id": "123456", + "path": "my/", + } + + # works with sub paths + assert router.dispatch(Request(path="/with-args/123456/my/path")).json == { + "some_id": "123456", + "path": "my/path", + } + + # no sub path with no trailing slash raises 404 + with pytest.raises(NotFound): + router.dispatch(Request(path="/with-args/123456")) + + # greedy path accepts empty sub path if there's a trailing slash + assert router.dispatch(Request(path="/with-args/123456/")).json == { + "some_id": "123456", + "path": "", + } + + # with the GreedyPath converter, we no longer redirect and accept the request + # in order the retrieve the double slash between parameter, we might need to use the RAW_URI + assert router.dispatch(Request(path="/with-args/123456//my/test//")).json == { + "some_id": "123456", + "path": "/my/test//", + } + def test_remove_rule(self): router = Router() diff --git a/tests/unit/test_apigateway.py b/tests/unit/test_apigateway.py index 08a14339d872f..e6f3812c543bd 100644 --- a/tests/unit/test_apigateway.py +++ b/tests/unit/test_apigateway.py @@ -859,17 +859,18 @@ def test_create_invocation_headers(): class TestApigatewayEvents: + # TODO: remove this tests, assertion are wrong def test_construct_invocation_event(self): tt = [ { "method": "GET", - "path": "http://localhost.localstack.cloud", + "path": "/test/path", "headers": {}, "data": None, "query_string_params": None, "is_base64_encoded": False, "expected": { - "path": "http://localhost.localstack.cloud", + "path": "/test/path", "headers": {}, "multiValueHeaders": {}, "body": None, @@ -881,13 +882,13 @@ def test_construct_invocation_event(self): }, { "method": "GET", - "path": "http://localhost.localstack.cloud", + "path": "/test/path", "headers": {}, "data": None, "query_string_params": {}, "is_base64_encoded": False, "expected": { - "path": "http://localhost.localstack.cloud", + "path": "/test/path", "headers": {}, "multiValueHeaders": {}, "body": None, @@ -899,13 +900,13 @@ def test_construct_invocation_event(self): }, { "method": "GET", - "path": "http://localhost.localstack.cloud", + "path": "/test/path", "headers": {}, "data": None, "query_string_params": {"foo": "bar"}, "is_base64_encoded": False, "expected": { - "path": "http://localhost.localstack.cloud", + "path": "/test/path", "headers": {}, "multiValueHeaders": {}, "body": None, @@ -917,13 +918,13 @@ def test_construct_invocation_event(self): }, { "method": "GET", - "path": "http://localhost.localstack.cloud?baz=qux", + "path": "/test/path?baz=qux", "headers": {}, "data": None, "query_string_params": {"foo": "bar"}, "is_base64_encoded": False, "expected": { - "path": "http://localhost.localstack.cloud?baz=qux", + "path": "/test/path?baz=qux", "headers": {}, "multiValueHeaders": {}, "body": None,