Skip to content

Commit 339c073

Browse files
authored
Fix apigw input path formatting (#12379)
1 parent c795e31 commit 339c073

File tree

5 files changed

+198
-2
lines changed

5 files changed

+198
-2
lines changed

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

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
from typing import Any, TypedDict
2222
from urllib.parse import quote_plus, unquote_plus
2323

24+
import airspeed
25+
from airspeed.operators import dict_to_string
26+
2427
from localstack import config
2528
from localstack.services.apigateway.next_gen.execute_api.variables import (
2629
ContextVariables,
@@ -50,6 +53,74 @@ class MappingTemplateVariables(TypedDict, total=False):
5053
stageVariables: dict[str, str]
5154

5255

56+
def cast_to_vtl_object(value):
57+
if isinstance(value, dict):
58+
return VTLMap(value)
59+
if isinstance(value, list):
60+
return [cast_to_vtl_object(item) for item in value]
61+
return value
62+
63+
64+
def cast_to_vtl_json_object(value: Any) -> Any:
65+
if isinstance(value, dict):
66+
return VTLJsonDict(value)
67+
if isinstance(value, list):
68+
return VTLJsonList(value)
69+
return value
70+
71+
72+
class VTLMap(dict):
73+
"""Overrides __str__ of python dict (and all child dict) to return a Java like string representation"""
74+
75+
# TODO apply this class more generally through the template mappings
76+
77+
def __init__(self, *args, **kwargs):
78+
super().__init__(*args, **kwargs)
79+
self.update(*args, **kwargs)
80+
81+
@staticmethod
82+
def cast_factory(value: Any) -> Any:
83+
return cast_to_vtl_object(value)
84+
85+
def update(self, *args, **kwargs):
86+
for k, v in self.items():
87+
self[k] = self.cast_factory(v)
88+
89+
def __str__(self) -> str:
90+
return dict_to_string(self)
91+
92+
93+
class VTLJsonList(list):
94+
"""Some VTL List behave differently when being represented as string and everything
95+
inside will be represented as a json string
96+
97+
Example: $input.path('$').b // Where path is {"a": 1, "b": [{"c": 5}]}
98+
Results: '[{"c":5}]' // Where everything inside the list is a valid json object
99+
"""
100+
101+
def __init__(self, *args):
102+
super(VTLJsonList, self).__init__(*args)
103+
for idx, item in enumerate(self):
104+
self[idx] = cast_to_vtl_json_object(item)
105+
106+
def __str__(self):
107+
if isinstance(self, list):
108+
return json.dumps(self, separators=(",", ":"))
109+
110+
111+
class VTLJsonDict(VTLMap):
112+
"""Some VTL Map behave differently when being represented as string and a list
113+
encountered in the dictionary will be represented as a json string
114+
115+
Example: $input.path('$') // Where path is {"a": 1, "b": [{"c": 5}]}
116+
Results: '{a=1, b=[{"c":5}]}' // Where everything inside the list is a valid json object
117+
"""
118+
119+
@staticmethod
120+
def cast_factory(value: Any) -> Any:
121+
return cast_to_vtl_json_object(value)
122+
123+
53124
class AttributeDict(dict):
54125
"""
55126
Wrapper returned by VelocityUtilApiGateway.parseJson to allow access to dict values as attributes (dot notation),
@@ -138,15 +209,18 @@ def __init__(self, body, params):
138209
self.parameters = params or {}
139210
self.value = body
140211

141-
def path(self, path):
212+
def _extract_json_path(self, path):
142213
if not self.value:
143214
return {}
144215
value = self.value if isinstance(self.value, dict) else json.loads(self.value)
145216
return extract_jsonpath(value, path)
146217

218+
def path(self, path):
219+
return cast_to_vtl_json_object(self._extract_json_path(path))
220+
147221
def json(self, path):
148222
path = path or "$"
149-
matching = self.path(path)
223+
matching = self._extract_json_path(path)
150224
if isinstance(matching, (list, dict)):
151225
matching = json_safe(matching)
152226
return json.dumps(matching)
@@ -202,3 +276,13 @@ def render_response(
202276
)
203277
result = self.render_vtl(template=template.strip(), variables=variables_copy)
204278
return result, variables_copy["context"]["responseOverride"]
279+
280+
281+
# patches required to allow our custom class operations in VTL templates processed by airspeed
282+
airspeed.operators.__additional_methods__[VTLMap] = airspeed.operators.__additional_methods__[dict]
283+
airspeed.operators.__additional_methods__[VTLJsonDict] = airspeed.operators.__additional_methods__[
284+
dict
285+
]
286+
airspeed.operators.__additional_methods__[VTLJsonList] = airspeed.operators.__additional_methods__[
287+
list
288+
]

localstack-core/localstack/testing/pytest/fixtures.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2170,11 +2170,16 @@ def echo_http_server(httpserver: HTTPServer):
21702170
"""Spins up a local HTTP echo server and returns the endpoint URL"""
21712171

21722172
def _echo(request: Request) -> Response:
2173+
request_json = None
2174+
if request.is_json:
2175+
with contextlib.suppress(ValueError):
2176+
request_json = json.loads(request.data)
21732177
result = {
21742178
"data": request.data or "{}",
21752179
"headers": dict(request.headers),
21762180
"url": request.url,
21772181
"method": request.method,
2182+
"json": request_json,
21782183
}
21792184
response_body = json.dumps(json_safe(result))
21802185
return Response(response_body, status=200)

tests/aws/services/apigateway/test_apigateway_common.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,95 @@ def _invoke_api(path: str, headers: dict[str, str]) -> dict[str, str]:
788788
# assert that AWS populated the parent part of the trace with a generated one
789789
assert split_trace[1] != hardcoded_parent
790790

791+
@markers.aws.validated
792+
def test_input_path_template_formatting(
793+
self, aws_client, create_rest_apigw, echo_http_server_post, snapshot
794+
):
795+
api_id, _, root_id = create_rest_apigw()
796+
797+
def _create_route(path: str, response_templates):
798+
resource_id = aws_client.apigateway.create_resource(
799+
restApiId=api_id, parentId=root_id, pathPart=path
800+
)["id"]
801+
aws_client.apigateway.put_method(
802+
restApiId=api_id,
803+
resourceId=resource_id,
804+
httpMethod="POST",
805+
authorizationType="NONE",
806+
apiKeyRequired=False,
807+
)
808+
809+
aws_client.apigateway.put_method_response(
810+
restApiId=api_id,
811+
resourceId=resource_id,
812+
httpMethod="POST",
813+
statusCode="200",
814+
)
815+
816+
aws_client.apigateway.put_integration(
817+
restApiId=api_id,
818+
resourceId=resource_id,
819+
httpMethod="POST",
820+
integrationHttpMethod="POST",
821+
type="HTTP",
822+
uri=echo_http_server_post,
823+
)
824+
825+
aws_client.apigateway.put_integration_response(
826+
restApiId=api_id,
827+
resourceId=resource_id,
828+
httpMethod="POST",
829+
statusCode="200",
830+
selectionPattern="",
831+
responseTemplates={"application/json": response_templates},
832+
)
833+
834+
_create_route("path", '#set($result = $input.path("$.json"))$result')
835+
_create_route("nested", '#set($result = $input.path("$.json"))$result.nested')
836+
_create_route("list", '#set($result = $input.path("$.json"))$result[0]')
837+
_create_route("to-string", '#set($result = $input.path("$.json"))$result.toString()')
838+
839+
stage_name = "dev"
840+
aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name)
841+
842+
url = api_invoke_url(api_id=api_id, stage=stage_name, path="/")
843+
path_url = url + "path"
844+
nested_url = url + "nested"
845+
list_url = url + "list"
846+
to_string = url + "to-string"
847+
848+
response = requests.post(path_url, json={"foo": "bar"})
849+
snapshot.match("dict-response", response.text)
850+
851+
response = requests.post(path_url, json=[{"foo": "bar"}])
852+
snapshot.match("json-list", response.text)
853+
854+
response = requests.post(nested_url, json={"nested": {"foo": "bar"}})
855+
snapshot.match("nested-dict", response.text)
856+
857+
response = requests.post(nested_url, json={"nested": [{"foo": "bar"}]})
858+
snapshot.match("nested-list", response.text)
859+
860+
response = requests.post(list_url, json=[{"foo": "bar"}])
861+
snapshot.match("dict-in-list", response.text)
862+
863+
response = requests.post(list_url, json=[[{"foo": "bar"}]])
864+
snapshot.match("list-with-nested-list", response.text)
865+
866+
response = requests.post(path_url, json={"foo": [{"nested": "bar"}]})
867+
snapshot.match("dict-with-nested-list", response.text)
868+
869+
response = requests.post(
870+
path_url, json={"bigger": "dict", "to": "test", "with": "separators"}
871+
)
872+
snapshot.match("bigger-dict", response.text)
873+
874+
response = requests.post(to_string, json={"foo": "bar"})
875+
snapshot.match("to-string", response.text)
876+
877+
response = requests.post(to_string, json={"list": [{"foo": "bar"}]})
878+
snapshot.match("list-to-string", response.text)
879+
791880

792881
class TestUsagePlans:
793882
@markers.aws.validated

tests/aws/services/apigateway/test_apigateway_common.snapshot.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,5 +1376,20 @@
13761376
"message": "Invalid request body"
13771377
}
13781378
}
1379+
},
1380+
"tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_input_path_template_formatting": {
1381+
"recorded-date": "12-03-2025, 21:18:25",
1382+
"recorded-content": {
1383+
"dict-response": "{foo=bar}",
1384+
"json-list": "[{\"foo\":\"bar\"}]",
1385+
"nested-dict": "{foo=bar}",
1386+
"nested-list": "[{\"foo\":\"bar\"}]",
1387+
"dict-in-list": "{foo=bar}",
1388+
"list-with-nested-list": "[{\"foo\":\"bar\"}]",
1389+
"dict-with-nested-list": "{foo=[{\"nested\":\"bar\"}]}",
1390+
"bigger-dict": "{bigger=dict, to=test, with=separators}",
1391+
"to-string": "{foo=bar}",
1392+
"list-to-string": "{list=[{\"foo\":\"bar\"}]}"
1393+
}
13791394
}
13801395
}

tests/aws/services/apigateway/test_apigateway_common.validation.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
"tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_api_gateway_request_validator_with_ref_one_ofmodels": {
99
"last_validated_date": "2024-10-28T23:12:21+00:00"
1010
},
11+
"tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_input_path_template_formatting": {
12+
"last_validated_date": "2025-03-12T21:18:25+00:00"
13+
},
1114
"tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_integration_request_parameters_mapping": {
1215
"last_validated_date": "2024-02-05T19:37:03+00:00"
1316
},

0 commit comments

Comments
 (0)