From b70d4e59eb2fb7f4494fdc589d67cef4a79ede08 Mon Sep 17 00:00:00 2001 From: Abhishek Tanguturi Date: Tue, 18 Nov 2025 02:43:27 +1000 Subject: [PATCH 1/2] 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 --- samtranslator/model/api/api_generator.py | 13 ++++ tests/model/api/test_api_generator.py | 79 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/samtranslator/model/api/api_generator.py b/samtranslator/model/api/api_generator.py index 53711bab99..6170d0cddc 100644 --- a/samtranslator/model/api/api_generator.py +++ b/samtranslator/model/api/api_generator.py @@ -980,7 +980,20 @@ def _add_cors(self) -> None: ) editor = SwaggerEditor(self.definition_body) + # Track normalized paths to avoid duplicate OPTIONS methods for paths that differ only by trailing slash + # API Gateway treats /path and /path/ as the same resource, so we normalize before adding CORS + normalized_paths_processed: Set[str] = set() + for path in editor.iter_on_path(): + # Normalize path by removing trailing slash (except for root path "/") + normalized_path = path.rstrip("/") if path != "/" else path + + # Skip if we've already processed this normalized path to avoid duplicate OPTIONS methods + if normalized_path in normalized_paths_processed: + continue + + normalized_paths_processed.add(normalized_path) + try: editor.add_cors( # type: ignore[no-untyped-call] path, diff --git a/tests/model/api/test_api_generator.py b/tests/model/api/test_api_generator.py index d12136e0f3..8f0a053c6a 100644 --- a/tests/model/api/test_api_generator.py +++ b/tests/model/api/test_api_generator.py @@ -49,3 +49,82 @@ def test_construct_usage_plan_with_invalid_usage_plan_fields(self, AuthPropertie with self.assertRaises(InvalidResourceException) as cm: api_generator._construct_usage_plan() self.assertIn("Invalid property for", str(cm.exception)) + + def test_add_cors_with_trailing_slash_paths(self): + """Test that CORS doesn't create duplicate OPTIONS methods for paths with/without trailing slash""" + # Create a simple swagger definition with paths that differ only by trailing slash + definition_body = { + "swagger": "2.0", + "info": {"title": "TestAPI", "version": "1.0"}, + "paths": { + "/datasets": { + "post": { + "x-amazon-apigateway-integration": { + "type": "aws_proxy", + "httpMethod": "POST", + "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/func/invocations" + } + } + }, + "/datasets/": { + "put": { + "x-amazon-apigateway-integration": { + "type": "aws_proxy", + "httpMethod": "POST", + "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/func/invocations" + } + } + } + } + } + + api_generator = ApiGenerator( + logical_id="TestApi", + cache_cluster_enabled=None, + cache_cluster_size=None, + variables=None, + depends_on=None, + definition_body=definition_body, + definition_uri=None, + name=None, + stage_name="Prod", + shared_api_usage_plan=None, # Added required parameter + template_conditions=None, # Added required parameter + tags=None, + endpoint_configuration=None, + method_settings=None, + binary_media=None, + minimum_compression_size=None, + cors="'*'", # Enable CORS + auth=None, + gateway_responses=None, + access_log_setting=None, + canary_setting=None, + tracing_enabled=None, + resource_attributes=None, + passthrough_resource_attributes=None, + open_api_version=None, + models=None, + domain=None, + fail_on_warnings=None, + description=None, + mode=None, + api_key_source_type=None, + disable_execute_api_endpoint=None, + ) + + # Call _add_cors which should normalize paths and avoid duplicates + api_generator._add_cors() + + # Check that OPTIONS method is not added to both /datasets and /datasets/ + # It should only be added once to avoid the duplicate OPTIONS error + paths_with_options = [ + path for path, methods in api_generator.definition_body["paths"].items() + if "options" in methods or "OPTIONS" in methods + ] + + # We should have only ONE path with OPTIONS method (the normalized one) + # Both /datasets and /datasets/ normalize to /datasets + self.assertEqual(len(paths_with_options), 1, + "CORS should only add OPTIONS to one of the paths that differ by trailing slash") + From 6b3afda3cc961ea405876c19db03924cd5029a16 Mon Sep 17 00:00:00 2001 From: Abhishek Tanguturi Date: Mon, 23 Feb 2026 20:14:02 +1000 Subject: [PATCH 2/2] Fix duplicate OPTIONS method error for paths with trailing slash Address review feedback: use normalized_path instead of path when adding CORS OPTIONS method. This ensures consistent behavior regardless of which path (/datasets vs /datasets/) appears first in the template. Fixes #3816 --- samtranslator/model/api/api_generator.py | 10 +++++----- tests/model/api/test_api_generator.py | 25 ++++++++++++------------ 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/samtranslator/model/api/api_generator.py b/samtranslator/model/api/api_generator.py index 6170d0cddc..e44b5b529c 100644 --- a/samtranslator/model/api/api_generator.py +++ b/samtranslator/model/api/api_generator.py @@ -983,20 +983,20 @@ def _add_cors(self) -> None: # Track normalized paths to avoid duplicate OPTIONS methods for paths that differ only by trailing slash # API Gateway treats /path and /path/ as the same resource, so we normalize before adding CORS normalized_paths_processed: Set[str] = set() - + for path in editor.iter_on_path(): # Normalize path by removing trailing slash (except for root path "/") normalized_path = path.rstrip("/") if path != "/" else path - + # Skip if we've already processed this normalized path to avoid duplicate OPTIONS methods if normalized_path in normalized_paths_processed: continue - + normalized_paths_processed.add(normalized_path) - + try: editor.add_cors( # type: ignore[no-untyped-call] - path, + normalized_path, properties.AllowOrigin, properties.AllowHeaders, properties.AllowMethods, diff --git a/tests/model/api/test_api_generator.py b/tests/model/api/test_api_generator.py index 8f0a053c6a..6a5be16eb2 100644 --- a/tests/model/api/test_api_generator.py +++ b/tests/model/api/test_api_generator.py @@ -62,7 +62,7 @@ def test_add_cors_with_trailing_slash_paths(self): "x-amazon-apigateway-integration": { "type": "aws_proxy", "httpMethod": "POST", - "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/func/invocations" + "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/func/invocations", } } }, @@ -71,13 +71,13 @@ def test_add_cors_with_trailing_slash_paths(self): "x-amazon-apigateway-integration": { "type": "aws_proxy", "httpMethod": "POST", - "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/func/invocations" + "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/func/invocations", } } - } - } + }, + }, } - + api_generator = ApiGenerator( logical_id="TestApi", cache_cluster_enabled=None, @@ -112,19 +112,20 @@ def test_add_cors_with_trailing_slash_paths(self): api_key_source_type=None, disable_execute_api_endpoint=None, ) - + # Call _add_cors which should normalize paths and avoid duplicates api_generator._add_cors() - + # Check that OPTIONS method is not added to both /datasets and /datasets/ # It should only be added once to avoid the duplicate OPTIONS error paths_with_options = [ - path for path, methods in api_generator.definition_body["paths"].items() + path + for path, methods in api_generator.definition_body["paths"].items() if "options" in methods or "OPTIONS" in methods ] - + # We should have only ONE path with OPTIONS method (the normalized one) # Both /datasets and /datasets/ normalize to /datasets - self.assertEqual(len(paths_with_options), 1, - "CORS should only add OPTIONS to one of the paths that differ by trailing slash") - + self.assertEqual( + len(paths_with_options), 1, "CORS should only add OPTIONS to one of the paths that differ by trailing slash" + )