Skip to content

Commit 7b9533a

Browse files
authored
APIGW: fix TestInvokeMethod path logic (#13030)
1 parent 56690b7 commit 7b9533a

File tree

9 files changed

+471
-108
lines changed

9 files changed

+471
-108
lines changed

localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/resource_router.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,11 @@ def match(self, context: RestApiInvocationContext) -> tuple[Resource, dict[str,
8585
rule, args = matcher.match(path, method=request.method, return_rule=True)
8686
except (MethodNotAllowed, NotFound) as e:
8787
# MethodNotAllowed (405) exception is raised if a path is matching, but the method does not.
88-
# Our router might handle this as a 404, validate with AWS.
88+
# AWS handles this and the regular 404 as a '403 MissingAuthTokenError'
8989
LOG.warning(
9090
"API Gateway: No resource or method was found for: %s %s",
9191
request.method,
9292
path,
93-
exc_info=LOG.isEnabledFor(logging.DEBUG),
9493
)
9594
raise MissingAuthTokenError("Missing Authentication Token") from e
9695

localstack-core/localstack/services/apigateway/next_gen/execute_api/test_invoke.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import logging
23
from urllib.parse import parse_qs
34

45
from rolo import Request
@@ -22,6 +23,9 @@
2223
ContextVarsResponseOverride,
2324
)
2425

26+
LOG = logging.getLogger(__name__)
27+
28+
2529
# TODO: we probably need to write and populate those logs as part of the handler chain itself
2630
# and store it in the InvocationContext. That way, we could also retrieve in when calling TestInvoke
2731

@@ -45,6 +49,19 @@
4549
{formatted_date} : Method completed with status: {method_response_status}
4650
"""
4751

52+
TEST_INVOKE_TEMPLATE_MOCK = """Execution log for request {request_id}
53+
{formatted_date} : Starting execution for request: {request_id}
54+
{formatted_date} : HTTP Method: {request_method}, Resource Path: {resource_path}
55+
{formatted_date} : Method request path: {method_request_path_parameters}
56+
{formatted_date} : Method request query string: {method_request_query_string}
57+
{formatted_date} : Method request headers: {method_request_headers}
58+
{formatted_date} : Method request body before transformations: {method_request_body}
59+
{formatted_date} : Method response body after transformations: {method_response_body}
60+
{formatted_date} : Method response headers: {method_response_headers}
61+
{formatted_date} : Successfully completed execution
62+
{formatted_date} : Method completed with status: {method_response_status}
63+
"""
64+
4865

4966
def _dump_headers(headers: Headers) -> str:
5067
if not headers:
@@ -93,6 +110,29 @@ def log_template(invocation_context: RestApiInvocationContext, response_headers:
93110
)
94111

95112

113+
def log_mock_template(
114+
invocation_context: RestApiInvocationContext, response_headers: Headers
115+
) -> str:
116+
formatted_date = datetime.datetime.now(tz=datetime.UTC).strftime("%a %b %d %H:%M:%S %Z %Y")
117+
request = invocation_context.invocation_request
118+
context_var = invocation_context.context_variables
119+
method_resp = invocation_context.invocation_response
120+
121+
return TEST_INVOKE_TEMPLATE_MOCK.format(
122+
formatted_date=formatted_date,
123+
request_id=context_var["requestId"],
124+
resource_path=request["path"],
125+
request_method=request["http_method"],
126+
method_request_path_parameters=dict_to_string(request["path_parameters"]),
127+
method_request_query_string=dict_to_string(request["query_string_parameters"]),
128+
method_request_headers=_dump_headers(request.get("headers")),
129+
method_request_body=to_str(request.get("body", "")),
130+
method_response_status=method_resp.get("status_code"),
131+
method_response_body=to_str(method_resp.get("body", "")),
132+
method_response_headers=_dump_headers(response_headers),
133+
)
134+
135+
96136
def create_test_chain() -> HandlerChain[RestApiInvocationContext]:
97137
return HandlerChain(
98138
request_handlers=[
@@ -114,13 +154,16 @@ def create_test_invocation_context(
114154
) -> RestApiInvocationContext:
115155
parse_handler = handlers.parse_request
116156
http_method = test_request["httpMethod"]
157+
resource = deployment.rest_api.resources[test_request["resourceId"]]
158+
resource_path = resource["path"]
117159

118160
# we do not need a true HTTP request for the context, as we are skipping all the parsing steps and using the
119161
# provider data
120162
invocation_context = RestApiInvocationContext(
121163
request=Request(method=http_method),
122164
)
123-
path_query = test_request.get("pathWithQueryString", "/").split("?")
165+
test_request_path = test_request.get("pathWithQueryString") or resource_path
166+
path_query = test_request_path.split("?")
124167
path = path_query[0]
125168
multi_query_args: dict[str, list[str]] = {}
126169

@@ -140,9 +183,22 @@ def create_test_invocation_context(
140183
# TODO: handle multiValueHeaders
141184
body=to_bytes(test_request.get("body") or ""),
142185
)
186+
143187
invocation_context.invocation_request = invocation_request
188+
try:
189+
# this is AWS behavior, it will accept any value for the `pathWithQueryString`, even if it doesn't match
190+
# the expected format. It will just fall back to no value if it cannot parse the path parameters out of it
191+
_, path_parameters = RestAPIResourceRouter(deployment).match(invocation_context)
192+
except Exception as e:
193+
LOG.warning(
194+
"Error while trying to extract path parameters from user-provided 'pathWithQueryString=%s' "
195+
"for the following resource path: '%s'. Error: '%s'",
196+
path,
197+
resource_path,
198+
e,
199+
)
200+
path_parameters = {}
144201

145-
_, path_parameters = RestAPIResourceRouter(deployment).match(invocation_context)
146202
invocation_request["path_parameters"] = path_parameters
147203

148204
invocation_context.deployment = deployment
@@ -160,7 +216,6 @@ def create_test_invocation_context(
160216
responseOverride=ContextVarsResponseOverride(header={}, status=0),
161217
)
162218
invocation_context.trace_id = parse_handler.populate_trace_id({})
163-
resource = deployment.rest_api.resources[test_request["resourceId"]]
164219
resource_method = resource["resourceMethods"][http_method]
165220
invocation_context.resource = resource
166221
invocation_context.resource_method = resource_method
@@ -179,8 +234,10 @@ def run_test_invocation(
179234
invocation_context = create_test_invocation_context(test_request, deployment)
180235

181236
test_chain = create_test_chain()
237+
is_mock_integration = invocation_context.integration["type"] == "MOCK"
238+
182239
# header order is important
183-
if invocation_context.integration["type"] == "MOCK":
240+
if is_mock_integration:
184241
base_headers = {"Content-Type": APPLICATION_JSON}
185242
else:
186243
# we manually add the trace-id, as it is normally added by handlers.response_enricher which adds to much data
@@ -199,7 +256,11 @@ def run_test_invocation(
199256
# AWS does not return the Content-Length for TestInvokeMethod
200257
response_headers.remove("Content-Length")
201258

202-
log = log_template(invocation_context, response_headers)
259+
if is_mock_integration:
260+
# TODO: revisit how we're building the logs
261+
log = log_mock_template(invocation_context, response_headers)
262+
else:
263+
log = log_template(invocation_context, response_headers)
203264

204265
headers = dict(response_headers)
205266
multi_value_headers = build_multi_value_headers(response_headers)

tests/aws/services/apigateway/conftest.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from itertools import count
2+
13
import pytest
24
from botocore.config import Config
35

@@ -238,3 +240,60 @@ def _import_apigateway_function(*args, **kwargs):
238240
def apigw_add_transformers(snapshot):
239241
snapshot.add_transformer(snapshot.transform.jsonpath("$..items..id", "id"))
240242
snapshot.add_transformer(snapshot.transform.key_value("deploymentId"))
243+
244+
245+
@pytest.fixture
246+
def apigw_test_invoke_response_formatter(snapshot):
247+
snapshot.add_transformers_list(
248+
[
249+
snapshot.transform.key_value(
250+
"latency", value_replacement="<latency>", reference_replacement=False
251+
),
252+
snapshot.transform.jsonpath(
253+
"$..headers.X-Amzn-Trace-Id", value_replacement="x-amz-trace-id"
254+
),
255+
snapshot.transform.regex(
256+
r"URI: https:\/\/.*?\/2015-03-31", "URI: https://<endpoint_url>/2015-03-31"
257+
),
258+
snapshot.transform.regex(
259+
r"Integration latency: \d*? ms", "Integration latency: <latency> ms"
260+
),
261+
snapshot.transform.regex(
262+
r"Date=[a-zA-Z]{3},\s\d{2}\s[a-zA-Z]{3}\s\d{4}\s\d{2}:\d{2}:\d{2}\sGMT",
263+
"Date=Day, dd MMM yyyy hh:mm:ss GMT",
264+
),
265+
snapshot.transform.regex(
266+
r"x-amzn-RequestId=[a-f0-9-]{36}", "x-amzn-RequestId=<request-id-lambda>"
267+
),
268+
snapshot.transform.regex(
269+
r"[a-zA-Z]{3}\s[a-zA-Z]{3}\s\d{2}\s\d{2}:\d{2}:\d{2}\sUTC\s\d{4} :",
270+
"DDD MMM dd hh:mm:ss UTC yyyy :",
271+
),
272+
snapshot.transform.regex(
273+
r"Authorization=.*?,", "Authorization=<authorization-header>,"
274+
),
275+
snapshot.transform.regex(
276+
r"X-Amz-Security-Token=.*?\s\[", "X-Amz-Security-Token=<token> ["
277+
),
278+
snapshot.transform.regex(r"\d{8}T\d{6}Z", "<date>"),
279+
]
280+
)
281+
282+
def _transform_log(_log: str) -> dict[str, str]:
283+
return {f"line{index:02d}": line for index, line in enumerate(_log.split("\n"))}
284+
285+
counter = count(start=1)
286+
287+
# we want to do very precise matching on the log, and splitting on new lines will help in case the snapshot
288+
# fails
289+
# the snapshot library does not allow to ignore an array index as the last node, so we need to put it in a dict
290+
291+
def transform_response(response: dict) -> dict:
292+
response["log"] = _transform_log(response["log"])
293+
request_id = response["log"]["line00"].split(" ")[-1]
294+
snapshot.add_transformer(
295+
snapshot.transform.regex(request_id, f"<request-id-{next(counter)}>"), priority=-1
296+
)
297+
return response
298+
299+
return transform_response

0 commit comments

Comments
 (0)