From 8f87daac970092291e0649ae714f5e1ace8b621e Mon Sep 17 00:00:00 2001 From: giria660 Date: Wed, 25 Mar 2026 22:06:23 -0400 Subject: [PATCH] Fix unique operation IDs for multi-method routes --- fastapi/openapi/utils.py | 9 ++++- fastapi/utils.py | 3 +- tests/test_generate_unique_id_function.py | 41 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 8f1852b0cc..90e5ca70b5 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -233,6 +233,13 @@ def generate_operation_summary(*, route: routing.APIRoute, method: str) -> str: return route.name.replace("_", " ").title() +def get_openapi_operation_id(*, route: routing.APIRoute, method: str) -> str: + operation_id = route.operation_id or route.unique_id + if route.operation_id is None and route.methods and len(route.methods) > 1: + operation_id = f"{operation_id}_{method.lower()}" + return operation_id + + def get_openapi_operation_metadata( *, route: routing.APIRoute, method: str, operation_ids: set[str] ) -> dict[str, Any]: @@ -242,7 +249,7 @@ def get_openapi_operation_metadata( operation["summary"] = generate_operation_summary(route=route, method=method) if route.description: operation["description"] = route.description - operation_id = route.operation_id or route.unique_id + operation_id = get_openapi_operation_id(route=route, method=method) if operation_id in operation_ids: endpoint_name = getattr(route.endpoint, "__name__", "") message = f"Duplicate Operation ID {operation_id} for function {endpoint_name}" diff --git a/fastapi/utils.py b/fastapi/utils.py index 12eaa2bf08..4779a784a2 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -96,7 +96,8 @@ def generate_unique_id(route: "APIRoute") -> str: operation_id = f"{route.name}{route.path_format}" operation_id = re.sub(r"\W", "_", operation_id) assert route.methods - operation_id = f"{operation_id}_{list(route.methods)[0].lower()}" + if len(route.methods) == 1: + operation_id = f"{operation_id}_{sorted(route.methods)[0].lower()}" return operation_id diff --git a/tests/test_generate_unique_id_function.py b/tests/test_generate_unique_id_function.py index c56e6d5794..db3ba6eaa3 100644 --- a/tests/test_generate_unique_id_function.py +++ b/tests/test_generate_unique_id_function.py @@ -1697,3 +1697,44 @@ def test_warn_duplicate_operation_id(): ] assert len(duplicate_warnings) > 0 assert "Duplicate Operation ID" in str(duplicate_warnings[0].message) + + +def test_multi_method_route_unique_operation_ids(): + app = FastAPI() + + @app.api_route("/clear", methods=["POST", "DELETE"]) + def clear(): + return None # pragma: nocover + + client = TestClient(app) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + duplicate_warnings = [ + warning + for warning in w + if issubclass(warning.category, UserWarning) + and "Duplicate Operation ID" in str(warning.message) + ] + assert duplicate_warnings == [] + path = response.json()["paths"]["/clear"] + assert path["post"]["operationId"] == "clear_clear_post" + assert path["delete"]["operationId"] == "clear_clear_delete" + + +def test_multi_method_route_unique_operation_ids_with_custom_generate_unique_id(): + app = FastAPI(generate_unique_id_function=custom_generate_unique_id) + + @app.api_route("/clear", methods=["POST", "DELETE"]) + def clear(): + return None # pragma: nocover + + client = TestClient(app) + response = client.get("/openapi.json") + + assert response.status_code == 200, response.text + path = response.json()["paths"]["/clear"] + assert path["post"]["operationId"] == "foo_clear_post" + assert path["delete"]["operationId"] == "foo_clear_delete"