diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 812003aee3..c7c92350cc 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -242,7 +242,14 @@ 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 is not None and len(route.methods) > 1: + # When a route handles multiple HTTP methods, append the current + # method to create unique operation IDs per method + operation_id = f"{route.unique_id}_{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/tests/test_duplicate_operation_id_multiple_methods.py b/tests/test_duplicate_operation_id_multiple_methods.py new file mode 100644 index 0000000000..53b103759d --- /dev/null +++ b/tests/test_duplicate_operation_id_multiple_methods.py @@ -0,0 +1,71 @@ +import warnings + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def test_no_duplicate_operation_id_with_multiple_methods(): + """Ensure routes with multiple methods get unique operation IDs.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + app = FastAPI() + + def handler(): + return {"status": "ok"} # pragma: nocover + + app.add_api_route("/items", handler, methods=["POST", "DELETE"]) + + client = TestClient(app) + response = client.get("/openapi.json") + assert response.status_code == 200 + schema = response.json() + + post_op = schema["paths"]["/items"]["post"] + delete_op = schema["paths"]["/items"]["delete"] + + assert post_op["operationId"] != delete_op["operationId"] + assert "post" in post_op["operationId"] + assert "delete" in delete_op["operationId"] + + dup_warnings = [x for x in w if "Duplicate" in str(x.message)] + assert len(dup_warnings) == 0 + + +def test_single_method_route_operation_id_unchanged(): + """Ensure single-method routes still generate the same operation IDs.""" + app = FastAPI() + + @app.get("/items") + def get_items(): + return [] # pragma: nocover + + client = TestClient(app) + response = client.get("/openapi.json") + schema = response.json() + + get_op = schema["paths"]["/items"]["get"] + assert get_op["operationId"] == "get_items_items_get" + + +def test_three_methods_unique_operation_ids(): + """Ensure routes with three methods all get unique operation IDs.""" + app = FastAPI() + + def handler(): + return {"status": "ok"} # pragma: nocover + + app.add_api_route("/items", handler, methods=["GET", "POST", "DELETE"]) + + client = TestClient(app) + response = client.get("/openapi.json") + schema = response.json() + + get_op = schema["paths"]["/items"]["get"] + post_op = schema["paths"]["/items"]["post"] + delete_op = schema["paths"]["/items"]["delete"] + + assert "get" in get_op["operationId"] + assert "post" in post_op["operationId"] + assert "delete" in delete_op["operationId"] + assert len({get_op["operationId"], post_op["operationId"], delete_op["operationId"]}) == 3