diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index c9b006a718..5e95493076 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -284,6 +284,11 @@ def get_openapi_path( route_response_media_type: Optional[str] = current_response_class.media_type if route.include_in_schema: for method in route.methods: + # Skip auto-added HEAD method in OpenAPI when it's paired with GET. + # HEAD is automatically supported for all GET endpoints per HTTP semantics. + # But explicit HEAD-only routes should still appear in the schema. + if method == "HEAD" and "GET" in route.methods: + continue operation = get_openapi_operation_metadata( route=route, method=method, operation_ids=operation_ids ) diff --git a/fastapi/routing.py b/fastapi/routing.py index fe8d886093..9efbc8f0e6 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -622,6 +622,9 @@ class APIRoute(routing.Route): if methods is None: methods = ["GET"] self.methods: set[str] = {method.upper() for method in methods} + # Automatically add HEAD for GET routes, following HTTP semantics and Starlette behavior + if "GET" in self.methods: + self.methods.add("HEAD") if isinstance(generate_unique_id_function, DefaultPlaceholder): current_generate_unique_id: Callable[[APIRoute], str] = ( generate_unique_id_function.value diff --git a/fastapi/utils.py b/fastapi/utils.py index da11fe2c77..37c3ad9659 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -108,7 +108,12 @@ 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()}" + # Use a deterministic method for the operation ID. + # Prefer non-HEAD methods since HEAD is often auto-added for GET routes. + # Sort to ensure consistent ordering across Python versions. + methods = sorted(route.methods) + method = next((m for m in methods if m != "HEAD"), methods[0]) + operation_id = f"{operation_id}_{method.lower()}" return operation_id diff --git a/tests/test_extra_routes.py b/tests/test_extra_routes.py index 251af4a59e..b1f5c4ce36 100644 --- a/tests/test_extra_routes.py +++ b/tests/test_extra_routes.py @@ -13,6 +13,14 @@ class Item(BaseModel): price: Optional[float] = None +# Note: HEAD route defined BEFORE GET route to allow custom HEAD behavior. +# GET routes automatically support HEAD, but explicit HEAD routes take precedence +# when defined first. +@app.head("/items/{item_id}") +def head_item(item_id: str): + return JSONResponse(None, headers={"x-fastapi-item-id": item_id}) + + @app.api_route("/items/{item_id}", methods=["GET"]) def get_items(item_id: str): return {"item_id": item_id} @@ -30,11 +38,6 @@ def delete_item(item_id: str, item: Item): return {"item_id": item_id, "item": item} -@app.head("/items/{item_id}") -def head_item(item_id: str): - return JSONResponse(None, headers={"x-fastapi-item-id": item_id}) - - @app.options("/items/{item_id}") def options_item(item_id: str): return JSONResponse(None, headers={"x-fastapi-item-id": item_id}) diff --git a/tests/test_head_method.py b/tests/test_head_method.py new file mode 100644 index 0000000000..d88c8c3efd --- /dev/null +++ b/tests/test_head_method.py @@ -0,0 +1,115 @@ +"""Test automatic HEAD method support for GET routes.""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def test_head_method_automatically_supported(): + """HEAD should work automatically for all GET endpoints.""" + app = FastAPI() + + @app.get("/") + def read_root(): + return {"hello": "world"} + + @app.get("/items/{item_id}") + def read_item(item_id: int): + return {"item_id": item_id} + + client = TestClient(app) + + # GET should work + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"hello": "world"} + + # HEAD should work and return same headers but no body + head_response = client.head("/") + assert head_response.status_code == 200 + assert head_response.content == b"" + assert head_response.headers.get("content-type") == "application/json" + assert int(head_response.headers.get("content-length")) > 0 + + # HEAD with path params + head_response = client.head("/items/42") + assert head_response.status_code == 200 + assert head_response.content == b"" + + +def test_head_not_in_openapi_schema(): + """HEAD should not appear in OpenAPI schema - it's implicit per HTTP semantics.""" + app = FastAPI() + + @app.get("/") + def read_root(): + return {"hello": "world"} + + @app.post("/items") + def create_item(): + return {"created": True} + + schema = app.openapi() + + # GET should be in schema + assert "get" in schema["paths"]["/"] + + # HEAD should NOT be in schema + assert "head" not in schema["paths"]["/"] + + # POST shouldn't have HEAD + assert "post" in schema["paths"]["/items"] + assert "head" not in schema["paths"]["/items"] + + +def test_head_not_added_for_post_routes(): + """HEAD should NOT be added for non-GET routes.""" + app = FastAPI() + + @app.post("/items") + def create_item(): + return {"created": True} + + client = TestClient(app) + + # POST should work + response = client.post("/items") + assert response.status_code == 200 + + # HEAD should NOT work for POST-only endpoint + head_response = client.head("/items") + assert head_response.status_code == 405 + + +def test_explicit_head_route(): + """User can still define explicit HEAD routes.""" + app = FastAPI() + + @app.head("/custom") + def custom_head(): + return None + + client = TestClient(app) + + response = client.head("/custom") + assert response.status_code == 200 + + +def test_head_preserves_response_headers(): + """HEAD response should have the same headers as GET.""" + app = FastAPI() + + @app.get("/") + def read_root(): + return {"data": "x" * 100} # Longer response + + client = TestClient(app) + + get_response = client.get("/") + head_response = client.head("/") + + assert head_response.headers.get("content-type") == get_response.headers.get( + "content-type" + ) + assert head_response.headers.get("content-length") == get_response.headers.get( + "content-length" + )