@@ -523,6 +523,39 @@ def test_response_is_json_with_multiple_json_content_types():
523523 assert response .is_json () is True
524524
525525
526+ def test_response_with_bytes_body_and_json_content_type_is_base64_encoded ():
527+ # GIVEN a route that returns a bytes body with a JSON content type
528+ app = ApiGatewayResolver ()
529+ body = b'{"a":1}'
530+
531+ @app .get ("/my/path" )
532+ def get_bytes () -> Response :
533+ return Response (200 , content_types .APPLICATION_JSON , body )
534+
535+ # WHEN calling the event handler
536+ result = app ({"path" : "/my/path" , "httpMethod" : "GET" }, None )
537+
538+ # THEN the bytes are base64 encoded instead of being passed to the JSON serializer
539+ assert result ["isBase64Encoded" ] is True
540+ assert base64 .b64decode (result ["body" ]) == body
541+
542+
543+ def test_response_with_lowercase_content_type_header_serializes_json_body ():
544+ # GIVEN a route that sets the JSON content type with a lowercase header name
545+ app = ApiGatewayResolver ()
546+
547+ @app .get ("/my/path" )
548+ def get_lowercase_header () -> Response :
549+ return Response (200 , headers = {"content-type" : content_types .APPLICATION_JSON }, body = {"a" : 1 })
550+
551+ # WHEN calling the event handler
552+ result = app ({"path" : "/my/path" , "httpMethod" : "GET" }, None )
553+
554+ # THEN the response is recognised as JSON and the body is serialized
555+ assert isinstance (result ["body" ], str )
556+ assert json .loads (result ["body" ]) == {"a" : 1 }
557+
558+
526559def test_compress ():
527560 # GIVEN a function that has compress=True
528561 # AND an event with a "Accept-Encoding" that include gzip
@@ -588,6 +621,32 @@ def return_text() -> Response:
588621 assert result ["body" ] == expected_value
589622
590623
624+ @pytest .mark .parametrize (
625+ "accept_encoding, expected_compressed" ,
626+ [
627+ pytest .param ("gzip;q=0" , False , id = "gzip refused with a zero weight" ),
628+ pytest .param ("deflate, gzip; q=0.0" , False , id = "gzip refused with a zero weight and spaces" ),
629+ pytest .param ("GZIP" , True , id = "gzip accepted in uppercase" ),
630+ pytest .param ("deflate, gzip;q=0.5" , True , id = "gzip accepted with a non-zero weight" ),
631+ ],
632+ )
633+ def test_compress_respects_accept_encoding_weights_and_casing (accept_encoding : str , expected_compressed : bool ):
634+ # GIVEN a function with compress=True
635+ app = ApiGatewayResolver ()
636+
637+ @app .get ("/my/path" , compress = True )
638+ def return_text () -> Response :
639+ return Response (200 , content_types .TEXT_PLAIN , "Foo" )
640+
641+ # WHEN the client sends an Accept-Encoding header with a weight or different casing
642+ event = {"path" : "/my/path" , "httpMethod" : "GET" , "headers" : {"Accept-Encoding" : accept_encoding }}
643+ result = app (event , None )
644+
645+ # THEN the response is only gzipped when the client accepts gzip
646+ assert result ["isBase64Encoded" ] is expected_compressed
647+ assert ("Content-Encoding" in result ["multiValueHeaders" ]) is expected_compressed
648+
649+
591650def test_compress_no_accept_encoding_null_headers ():
592651 # GIVEN a function with compress=True
593652 # AND the request has no headers
@@ -784,6 +843,59 @@ def get_with_cors():
784843 assert "Access-Control-Allow-Origin" not in headers
785844
786845
846+ def test_cors_adds_vary_origin_when_echoing_a_specific_origin ():
847+ # GIVEN a cors configuration with more than one allowed origin
848+ cors_config = CORSConfig (allow_origin = "https://a.com" , extra_origins = ["https://b.com" ])
849+ app = ApiGatewayResolver (cors = cors_config )
850+
851+ @app .get ("/cors" )
852+ def get_with_cors ():
853+ return {}
854+
855+ # WHEN calling the event handler with one of the allowed origins
856+ event = {"path" : "/cors" , "httpMethod" : "GET" , "headers" : {"Origin" : "https://a.com" }}
857+ result = app (event , None )
858+
859+ # THEN the echoed origin comes with Vary: Origin so shared caches don't reuse it for other origins
860+ headers = result ["multiValueHeaders" ]
861+ assert headers ["Access-Control-Allow-Origin" ] == ["https://a.com" ]
862+ assert headers ["Vary" ] == ["Origin" ]
863+
864+
865+ def test_cors_keeps_existing_vary_header_when_adding_origin ():
866+ # GIVEN a route that already sets a Vary header
867+ app = ApiGatewayResolver (cors = CORSConfig (allow_origin = "https://a.com" ))
868+
869+ @app .get ("/cors" )
870+ def get_with_vary () -> Response :
871+ return Response (200 , content_types .APPLICATION_JSON , "{}" , headers = {"Vary" : "Accept-Encoding" })
872+
873+ # WHEN calling the event handler with the allowed origin
874+ event = {"path" : "/cors" , "httpMethod" : "GET" , "headers" : {"Origin" : "https://a.com" }}
875+ result = app (event , None )
876+
877+ # THEN Origin is added to the existing Vary value instead of replacing it
878+ assert result ["multiValueHeaders" ]["Vary" ] == ["Accept-Encoding, Origin" ]
879+
880+
881+ def test_cors_does_not_add_vary_origin_for_wildcard_origin ():
882+ # GIVEN a cors configuration that allows every origin
883+ app = ApiGatewayResolver (cors = CORSConfig (allow_origin = "*" ))
884+
885+ @app .get ("/cors" )
886+ def get_with_cors ():
887+ return {}
888+
889+ # WHEN calling the event handler with any origin
890+ event = {"path" : "/cors" , "httpMethod" : "GET" , "headers" : {"Origin" : "https://a.com" }}
891+ result = app (event , None )
892+
893+ # THEN the wildcard response is the same for every origin, so no Vary header is needed
894+ headers = result ["multiValueHeaders" ]
895+ assert headers ["Access-Control-Allow-Origin" ] == ["*" ]
896+ assert "Vary" not in headers
897+
898+
787899def test_custom_cors_config ():
788900 # GIVEN a custom cors configuration
789901 allow_header = ["foo2" ]
@@ -1146,6 +1258,35 @@ def test_debug_print_event(capsys):
11461258 assert json .loads (output ) == event
11471259
11481260
1261+ @pytest .mark .parametrize (
1262+ "rule, path, expected_route_keys" ,
1263+ [
1264+ pytest .param (".+" , "/any/deep/path" , {}, id = "catch-all matches any path" ),
1265+ pytest .param ("/files/.+" , "/files/reports/2026/summary.pdf" , {}, id = "catch-all after a static prefix" ),
1266+ pytest .param (
1267+ "/users/<user_id>/files/.+" ,
1268+ "/users/123/files/docs/report.txt" ,
1269+ {"user_id" : "123" },
1270+ id = "catch-all after a dynamic parameter" ,
1271+ ),
1272+ ],
1273+ )
1274+ def test_route_rule_supports_documented_catch_all_regex (rule : str , path : str , expected_route_keys : dict ):
1275+ # GIVEN a route that uses a regex catch-all, as shown in the "Catch-all routes" docs
1276+ app = APIGatewayRestResolver ()
1277+
1278+ @app .get (rule )
1279+ def handler (** route_keys ):
1280+ return route_keys
1281+
1282+ # WHEN calling the event handler with a nested path
1283+ result = app ({"httpMethod" : "GET" , "path" : path }, None )
1284+
1285+ # THEN the regex in the rule matches the whole path and dynamic parameters are still captured
1286+ assert result ["statusCode" ] == 200
1287+ assert json .loads (result ["body" ]) == expected_route_keys
1288+
1289+
11491290def test_similar_dynamic_routes ():
11501291 # GIVEN
11511292 app = ApiGatewayResolver ()
@@ -1323,6 +1464,29 @@ def foo(): ...
13231464 assert response ["statusCode" ] == 200
13241465
13251466
1467+ @pytest .mark .parametrize (
1468+ "path" ,
1469+ [
1470+ pytest .param ("/v1/users/v2" , id = "prefix pattern also appears later in the path" ),
1471+ pytest .param ("/users/v2" , id = "prefix pattern only appears later in the path" ),
1472+ ],
1473+ )
1474+ def test_remove_prefix_by_regex_only_strips_leading_match (path : str ):
1475+ # GIVEN a regex strip_prefix that could also match a later path segment
1476+ app = ApiGatewayResolver (strip_prefixes = [re .compile (r"/v[1-9]+" )])
1477+
1478+ @app .get ("/users/<name>" )
1479+ def get_user (name : str ):
1480+ return {"name" : name }
1481+
1482+ # WHEN calling the handler with the pattern repeated later in the path
1483+ response = app ({"httpMethod" : "GET" , "path" : path }, None )
1484+
1485+ # THEN only a leading match is stripped, so the route matches and keeps the later segment
1486+ assert response ["statusCode" ] == 200
1487+ assert json .loads (response ["body" ]) == {"name" : "v2" }
1488+
1489+
13261490def test_empty_path_when_using_regexes ():
13271491 app = ApiGatewayResolver (strip_prefixes = [re .compile (r"/(dev|stg)" )])
13281492
0 commit comments