From ea255c4cfe851994d372e8dca6e6fff4159cdc4e Mon Sep 17 00:00:00 2001 From: Vishnu Kosuri Date: Sat, 14 Mar 2026 15:51:20 +0530 Subject: [PATCH] Fix duplicate operationId when route has multiple methods When a route is registered with multiple HTTP methods via add_api_route(), generate_unique_id() uses list(route.methods)[0] which is non-deterministic (methods is a set) and the same for every method in the route. This causes get_openapi_operation_metadata to assign identical operationIds to POST and DELETE (or any other method pair), triggering "Duplicate Operation ID" warnings. Two fixes: - generate_unique_id: use sorted(route.methods)[0] so the fallback unique_id is deterministic for single-method routes - get_openapi_operation_metadata: for multi-method routes without an explicit operation_id, derive the operationId from the route name/path plus the per-iteration method instead of reusing route.unique_id Fixes #13175 Co-Authored-By: Claude Sonnet 4.6 --- fastapi/openapi/utils.py | 9 ++++++- fastapi/utils.py | 2 +- tests/test_generate_unique_id_function.py | 29 +++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 828442559b..a72cff438e 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -1,6 +1,7 @@ import copy import http.client import inspect +import re import warnings from collections.abc import Sequence from typing import Any, Literal, cast @@ -243,7 +244,13 @@ 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 + if route.operation_id: + operation_id = route.operation_id + elif route.methods and len(route.methods) > 1: + base = re.sub(r"\W", "_", f"{route.name}{route.path_format}") + operation_id = f"{base}_{method.lower()}" + else: + operation_id = route.unique_id if operation_id in operation_ids: message = ( f"Duplicate Operation ID {operation_id} for function " diff --git a/fastapi/utils.py b/fastapi/utils.py index 12eaa2bf08..9a8683e081 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -96,7 +96,7 @@ 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()}" + 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..a3ec06f703 100644 --- a/tests/test_generate_unique_id_function.py +++ b/tests/test_generate_unique_id_function.py @@ -1697,3 +1697,32 @@ def test_warn_duplicate_operation_id(): ] assert len(duplicate_warnings) > 0 assert "Duplicate Operation ID" in str(duplicate_warnings[0].message) + + +def test_no_duplicate_operation_id_for_multi_method_route(): + app = FastAPI() + + def clear(): + return "cleared" # pragma: nocover + + app.router.add_api_route("/clear", clear, methods=["POST", "DELETE"]) + + client = TestClient(app) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + response = client.get("/openapi.json") + duplicate_warnings = [ + warning + for warning in w + if issubclass(warning.category, UserWarning) + and "Duplicate Operation ID" in str(warning.message) + ] + assert len(duplicate_warnings) == 0 + + schema = response.json() + clear_path = schema["paths"]["/clear"] + assert "post" in clear_path + assert "delete" in clear_path + assert clear_path["post"]["operationId"] != clear_path["delete"]["operationId"] + assert clear_path["post"]["operationId"] == "clear_clear_post" + assert clear_path["delete"]["operationId"] == "clear_clear_delete"