Skip to content

Commit 5d0293a

Browse files
committed
Fix duplicate OPTIONS method error for paths with trailing slash
Fixes #3816 When CORS is enabled on an API with paths that differ only by a trailing slash (e.g., /datasets and /datasets/), API Gateway would throw an error: 'Duplicate method OPTIONS found for resource /datasets. Only one is allowed.' This occurs because API Gateway treats /path and /path/ as the same resource, but SAM was adding OPTIONS methods to both paths separately. Solution: - Added path normalization in ApiGenerator._add_cors() method - Paths are normalized by removing trailing slashes (except for root '/') - Track normalized paths in a set to skip duplicates - Ensures only one OPTIONS method is added per normalized path Testing: - Added unit test test_add_cors_with_trailing_slash_paths to verify fix - All existing CORS integration tests (68 tests) pass without regression - Tested with real-world production templates with multiple HTTP methods
1 parent a9a9465 commit 5d0293a

File tree

2 files changed

+92
-0
lines changed

2 files changed

+92
-0
lines changed

samtranslator/model/api/api_generator.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,7 +975,20 @@ def _add_cors(self) -> None:
975975
)
976976

977977
editor = SwaggerEditor(self.definition_body)
978+
# Track normalized paths to avoid duplicate OPTIONS methods for paths that differ only by trailing slash
979+
# API Gateway treats /path and /path/ as the same resource, so we normalize before adding CORS
980+
normalized_paths_processed: Set[str] = set()
981+
978982
for path in editor.iter_on_path():
983+
# Normalize path by removing trailing slash (except for root path "/")
984+
normalized_path = path.rstrip("/") if path != "/" else path
985+
986+
# Skip if we've already processed this normalized path to avoid duplicate OPTIONS methods
987+
if normalized_path in normalized_paths_processed:
988+
continue
989+
990+
normalized_paths_processed.add(normalized_path)
991+
979992
try:
980993
editor.add_cors( # type: ignore[no-untyped-call]
981994
path,

tests/model/api/test_api_generator.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,82 @@ def test_construct_usage_plan_with_invalid_usage_plan_fields(self, AuthPropertie
4949
with self.assertRaises(InvalidResourceException) as cm:
5050
api_generator._construct_usage_plan()
5151
self.assertIn("Invalid property for", str(cm.exception))
52+
53+
def test_add_cors_with_trailing_slash_paths(self):
54+
"""Test that CORS doesn't create duplicate OPTIONS methods for paths with/without trailing slash"""
55+
# Create a simple swagger definition with paths that differ only by trailing slash
56+
definition_body = {
57+
"swagger": "2.0",
58+
"info": {"title": "TestAPI", "version": "1.0"},
59+
"paths": {
60+
"/datasets": {
61+
"post": {
62+
"x-amazon-apigateway-integration": {
63+
"type": "aws_proxy",
64+
"httpMethod": "POST",
65+
"uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/func/invocations"
66+
}
67+
}
68+
},
69+
"/datasets/": {
70+
"put": {
71+
"x-amazon-apigateway-integration": {
72+
"type": "aws_proxy",
73+
"httpMethod": "POST",
74+
"uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/func/invocations"
75+
}
76+
}
77+
}
78+
}
79+
}
80+
81+
api_generator = ApiGenerator(
82+
logical_id="TestApi",
83+
cache_cluster_enabled=None,
84+
cache_cluster_size=None,
85+
variables=None,
86+
depends_on=None,
87+
definition_body=definition_body,
88+
definition_uri=None,
89+
name=None,
90+
stage_name="Prod",
91+
shared_api_usage_plan=None, # Added required parameter
92+
template_conditions=None, # Added required parameter
93+
tags=None,
94+
endpoint_configuration=None,
95+
method_settings=None,
96+
binary_media=None,
97+
minimum_compression_size=None,
98+
cors="'*'", # Enable CORS
99+
auth=None,
100+
gateway_responses=None,
101+
access_log_setting=None,
102+
canary_setting=None,
103+
tracing_enabled=None,
104+
resource_attributes=None,
105+
passthrough_resource_attributes=None,
106+
open_api_version=None,
107+
models=None,
108+
domain=None,
109+
fail_on_warnings=None,
110+
description=None,
111+
mode=None,
112+
api_key_source_type=None,
113+
disable_execute_api_endpoint=None,
114+
)
115+
116+
# Call _add_cors which should normalize paths and avoid duplicates
117+
api_generator._add_cors()
118+
119+
# Check that OPTIONS method is not added to both /datasets and /datasets/
120+
# It should only be added once to avoid the duplicate OPTIONS error
121+
paths_with_options = [
122+
path for path, methods in api_generator.definition_body["paths"].items()
123+
if "options" in methods or "OPTIONS" in methods
124+
]
125+
126+
# We should have only ONE path with OPTIONS method (the normalized one)
127+
# Both /datasets and /datasets/ normalize to /datasets
128+
self.assertEqual(len(paths_with_options), 1,
129+
"CORS should only add OPTIONS to one of the paths that differ by trailing slash")
130+

0 commit comments

Comments
 (0)