diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index bcad0be75..6ed0d22b8 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -244,6 +244,19 @@ def get_openapi_operation_metadata( if route.description: operation["description"] = route.description operation_id = route.operation_id or route.unique_id + # If the route has multiple methods and operation_id was not explicitly set, + # append the method to make each operation ID unique + if ( + not route.operation_id + and route.methods + and len(route.methods) > 1 + ): + # unique_id already includes a method suffix, so we need to replace it + # with the actual method being processed + # unique_id format: "{name}_{path}_{first_method}" + # We need to replace the last method suffix with the current method + base_id = operation_id.rsplit("_", 1)[0] + operation_id = f"{base_id}_{method.lower()}" if operation_id in operation_ids: message = ( f"Duplicate Operation ID {operation_id} for function " diff --git a/tests/test_duplicate_operation_id.py b/tests/test_duplicate_operation_id.py new file mode 100644 index 000000000..1a984a771 --- /dev/null +++ b/tests/test_duplicate_operation_id.py @@ -0,0 +1,122 @@ +""" +Test that routes with multiple methods get unique operation IDs. +Related to issue #13175 +""" +import warnings + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def test_multiple_methods_unique_operation_ids(): + """Test that a route with multiple methods generates unique operation IDs""" + app = FastAPI() + + @app.api_route("/clear", methods=["POST", "DELETE"]) + def clear(): + return {"message": "cleared"} + + # Capture warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Get OpenAPI schema which triggers operation ID generation + openapi_schema = app.openapi() + + # Check that no duplicate operation ID warning was raised + duplicate_warnings = [ + warning for warning in w + if "Duplicate Operation ID" in str(warning.message) + ] + assert len(duplicate_warnings) == 0, ( + f"Found {len(duplicate_warnings)} duplicate operation ID warnings: " + f"{[str(w.message) for w in duplicate_warnings]}" + ) + + # Verify both methods are in the schema with different operation IDs + assert "/clear" in openapi_schema["paths"] + clear_path = openapi_schema["paths"]["/clear"] + + # Both POST and DELETE should be present + assert "post" in clear_path + assert "delete" in clear_path + + # Operation IDs should be different + post_op_id = clear_path["post"]["operationId"] + delete_op_id = clear_path["delete"]["operationId"] + + assert post_op_id != delete_op_id, ( + f"Operation IDs should be different: " + f"POST={post_op_id}, DELETE={delete_op_id}" + ) + + +def test_multiple_routes_with_multiple_methods(): + """Test multiple routes each with multiple methods""" + app = FastAPI() + + @app.api_route("/clear", methods=["POST", "DELETE"]) + def clear(): + return {"message": "cleared"} + + @app.api_route("/reset", methods=["POST", "PUT"]) + def reset(): + return {"message": "reset"} + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + openapi_schema = app.openapi() + + duplicate_warnings = [ + warning for warning in w + if "Duplicate Operation ID" in str(warning.message) + ] + assert len(duplicate_warnings) == 0 + + # Verify all operation IDs are unique + operation_ids = set() + for path_data in openapi_schema["paths"].values(): + for method_data in path_data.values(): + if isinstance(method_data, dict) and "operationId" in method_data: + op_id = method_data["operationId"] + assert op_id not in operation_ids, f"Duplicate operation ID: {op_id}" + operation_ids.add(op_id) + + +def test_single_method_route_unchanged(): + """Test that routes with single methods still work as before""" + app = FastAPI() + + @app.get("/items") + def get_items(): + return {"items": []} + + openapi_schema = app.openapi() + + # Should have the expected structure + assert "/items" in openapi_schema["paths"] + assert "get" in openapi_schema["paths"]["/items"] + assert "operationId" in openapi_schema["paths"]["/items"]["get"] + + +def test_add_api_route_with_multiple_methods(): + """Test using add_api_route directly with multiple methods""" + app = FastAPI() + + def clear_handler(): + return {"message": "cleared"} + + app.add_api_route("/clear", clear_handler, methods=["POST", "DELETE"]) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + openapi_schema = app.openapi() + + duplicate_warnings = [ + warning for warning in w + if "Duplicate Operation ID" in str(warning.message) + ] + assert len(duplicate_warnings) == 0 + + clear_path = openapi_schema["paths"]["/clear"] + assert clear_path["post"]["operationId"] != clear_path["delete"]["operationId"]