Browse Source

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 <[email protected]>
pull/15114/head
Vishnu Kosuri 4 months ago
parent
commit
ea255c4cfe
  1. 9
      fastapi/openapi/utils.py
  2. 2
      fastapi/utils.py
  3. 29
      tests/test_generate_unique_id_function.py

9
fastapi/openapi/utils.py

@ -1,6 +1,7 @@
import copy import copy
import http.client import http.client
import inspect import inspect
import re
import warnings import warnings
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any, Literal, cast from typing import Any, Literal, cast
@ -243,7 +244,13 @@ def get_openapi_operation_metadata(
operation["summary"] = generate_operation_summary(route=route, method=method) operation["summary"] = generate_operation_summary(route=route, method=method)
if route.description: if route.description:
operation["description"] = 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: if operation_id in operation_ids:
message = ( message = (
f"Duplicate Operation ID {operation_id} for function " f"Duplicate Operation ID {operation_id} for function "

2
fastapi/utils.py

@ -96,7 +96,7 @@ def generate_unique_id(route: "APIRoute") -> str:
operation_id = f"{route.name}{route.path_format}" operation_id = f"{route.name}{route.path_format}"
operation_id = re.sub(r"\W", "_", operation_id) operation_id = re.sub(r"\W", "_", operation_id)
assert route.methods 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 return operation_id

29
tests/test_generate_unique_id_function.py

@ -1697,3 +1697,32 @@ def test_warn_duplicate_operation_id():
] ]
assert len(duplicate_warnings) > 0 assert len(duplicate_warnings) > 0
assert "Duplicate Operation ID" in str(duplicate_warnings[0].message) 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"

Loading…
Cancel
Save