Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.

Commit 8554f9a

Browse files
authored
APIGW: handle undeclared Sparse Maps in specs (#13753)
1 parent ff1d403 commit 8554f9a

File tree

7 files changed

+146
-2
lines changed

7 files changed

+146
-2
lines changed

localstack-core/localstack/aws/api/apigateway/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1138,11 +1138,14 @@ class TlsConfig(TypedDict, total=False):
11381138
insecureSkipVerification: Boolean | None
11391139

11401140

1141+
MapOfStringToNullableString = dict[String, String | None]
1142+
1143+
11411144
class IntegrationResponse(TypedDict, total=False):
11421145
statusCode: StatusCode | None
11431146
selectionPattern: String | None
11441147
responseParameters: MapOfStringToString | None
1145-
responseTemplates: MapOfStringToString | None
1148+
responseTemplates: MapOfStringToNullableString | None
11461149
contentHandling: ContentHandlingStrategy | None
11471150

11481151

localstack-core/localstack/aws/protocol/serializer.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1533,6 +1533,17 @@ def _serialize_content_type(
15331533
if has_body and not has_content_type:
15341534
serialized.headers["Content-Type"] = mime_type
15351535

1536+
def _serialize_type_map(
1537+
self, body: dict, value: dict, shape: MapShape, key: str, mime_type: str
1538+
):
1539+
# REST JSON is different from regular JSON, it returns None values in Map
1540+
if value is None:
1541+
return
1542+
map_obj = {}
1543+
body[key] = map_obj
1544+
for sub_key, sub_value in value.items():
1545+
self._serialize(map_obj, sub_value, shape.value, sub_key, mime_type)
1546+
15361547

15371548
class BaseCBORResponseSerializer(ResponseSerializer):
15381549
"""

localstack-core/localstack/aws/scaffold.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ def html_to_rst(html: str):
6464
return rst
6565

6666

67+
def is_sparse_shape(shape: ListShape | MapShape):
68+
# see https://smithy.io/2.0/spec/type-refinement-traits.html#sparse-trait
69+
# this is needed for generating Map that can have nullable values
70+
# We need to access `_shape_model` directly, because `sparse` is not used by Botocore, so it is not picked in
71+
# `metadata`: see ``botocore.model.Shape.METADATA_ATTRS`` and ``botocore.model.Shape.metadata``
72+
return getattr(shape, "_shape_model", {}).get("sparse")
73+
74+
6775
class ShapeNode:
6876
service: ServiceModel
6977
shape: Shape
@@ -258,8 +266,12 @@ def print_declaration(self, output, doc=True, quote_types=False):
258266
f"{to_valid_python_name(shape.name)} = list[{q}{to_valid_python_name(shape.member.name)}{q}]"
259267
)
260268
elif isinstance(shape, MapShape):
269+
if is_sparse_shape(shape):
270+
value_key = f"{q}{to_valid_python_name(shape.value.name)} | None{q}"
271+
else:
272+
value_key = f"{q}{to_valid_python_name(shape.value.name)}{q}"
261273
output.write(
262-
f"{to_valid_python_name(shape.name)} = dict[{q}{to_valid_python_name(shape.key.name)}{q}, {q}{to_valid_python_name(shape.value.name)}{q}]"
274+
f"{to_valid_python_name(shape.name)} = dict[{q}{to_valid_python_name(shape.key.name)}{q}, {value_key}]"
263275
)
264276
elif isinstance(shape, StringShape):
265277
if shape.enum:

localstack-core/localstack/aws/spec-patches.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,5 +1372,22 @@
13721372
"path": "/operations/CreateApiMapping/http/responseCode",
13731373
"value": 200
13741374
}
1375+
],
1376+
"apigateway/2015-07-09/service-2": [
1377+
{
1378+
"op": "add",
1379+
"path": "/shapes/MapOfStringToNullableString",
1380+
"value": {
1381+
"type":"map",
1382+
"key":{"shape":"String"},
1383+
"value":{"shape":"String"},
1384+
"sparse": true
1385+
}
1386+
},
1387+
{
1388+
"op": "replace",
1389+
"path": "/shapes/IntegrationResponse/members/responseTemplates/shape",
1390+
"value": "MapOfStringToNullableString"
1391+
}
13751392
]
13761393
}

tests/aws/services/apigateway/test_apigateway_api.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3273,6 +3273,63 @@ def test_put_integration_response_validation(
32733273

32743274
snapshot.match("put-integration-response-wrong-resource", e.value.response)
32753275

3276+
@markers.aws.validated
3277+
def test_put_integration_response_templates(
3278+
self, aws_client, apigw_create_rest_api, aws_client_factory, snapshot
3279+
):
3280+
apigw_client = aws_client_factory(config=Config(parameter_validation=False)).apigateway
3281+
response = apigw_create_rest_api(
3282+
name=f"test-api-{short_uid()}", description="testing PutIntegrationResponse method exc"
3283+
)
3284+
api_id = response["id"]
3285+
root_id = response["rootResourceId"]
3286+
3287+
aws_client.apigateway.put_method(
3288+
restApiId=api_id,
3289+
resourceId=root_id,
3290+
httpMethod="POST",
3291+
authorizationType="NONE",
3292+
)
3293+
3294+
aws_client.apigateway.put_integration(
3295+
restApiId=api_id,
3296+
resourceId=root_id,
3297+
httpMethod="POST",
3298+
integrationHttpMethod="GET",
3299+
type="MOCK",
3300+
requestTemplates={"application/json": '{"statusCode": 200}'},
3301+
)
3302+
3303+
response = apigw_client.put_integration_response(
3304+
restApiId=api_id,
3305+
resourceId=root_id,
3306+
httpMethod="POST",
3307+
statusCode="200",
3308+
selectionPattern="",
3309+
responseTemplates={"application/json": None},
3310+
)
3311+
3312+
snapshot.match("put-integration-response-template-none", response)
3313+
3314+
response = apigw_client.put_integration_response(
3315+
restApiId=api_id,
3316+
resourceId=root_id,
3317+
httpMethod="POST",
3318+
statusCode="200",
3319+
selectionPattern="",
3320+
responseTemplates={"application/json": ""},
3321+
)
3322+
snapshot.match("put-integration-response-template-empty", response)
3323+
3324+
response = apigw_client.put_integration_response(
3325+
restApiId=api_id,
3326+
resourceId=root_id,
3327+
httpMethod="POST",
3328+
statusCode="200",
3329+
selectionPattern="",
3330+
)
3331+
snapshot.match("put-integration-no-response-template", response)
3332+
32763333
@markers.aws.validated
32773334
@pytest.mark.skipif(
32783335
condition=not is_aws_cloud(), reason="Validation behavior not yet implemented"

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5322,5 +5322,40 @@
53225322
}
53235323
}
53245324
}
5325+
},
5326+
"tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_response_templates": {
5327+
"recorded-date": "12-02-2026, 16:25:03",
5328+
"recorded-content": {
5329+
"put-integration-response-template-none": {
5330+
"responseTemplates": {
5331+
"application/json": null
5332+
},
5333+
"selectionPattern": "",
5334+
"statusCode": "200",
5335+
"ResponseMetadata": {
5336+
"HTTPHeaders": {},
5337+
"HTTPStatusCode": 201
5338+
}
5339+
},
5340+
"put-integration-response-template-empty": {
5341+
"responseTemplates": {
5342+
"application/json": null
5343+
},
5344+
"selectionPattern": "",
5345+
"statusCode": "200",
5346+
"ResponseMetadata": {
5347+
"HTTPHeaders": {},
5348+
"HTTPStatusCode": 201
5349+
}
5350+
},
5351+
"put-integration-no-response-template": {
5352+
"selectionPattern": "",
5353+
"statusCode": "200",
5354+
"ResponseMetadata": {
5355+
"HTTPHeaders": {},
5356+
"HTTPStatusCode": 201
5357+
}
5358+
}
5359+
}
53255360
}
53265361
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,15 @@
335335
"tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_request_parameter_bool_type": {
336336
"last_validated_date": "2024-12-12T10:46:41+00:00"
337337
},
338+
"tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_response_templates": {
339+
"last_validated_date": "2026-02-12T16:25:03+00:00",
340+
"durations_in_seconds": {
341+
"setup": 0.9,
342+
"call": 1.65,
343+
"teardown": 0.29,
344+
"total": 2.84
345+
}
346+
},
338347
"tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_wrong_type": {
339348
"last_validated_date": "2024-04-15T20:48:47+00:00"
340349
},

0 commit comments

Comments
 (0)