From d201fdcdfab5f613bf47d8cd4bcbf4a1f5f730e9 Mon Sep 17 00:00:00 2001 From: Jonathan Fulton Date: Sat, 31 Jan 2026 18:54:27 -0500 Subject: [PATCH] feat: automatically support HEAD method for all GET routes (#1773) Following HTTP semantics and Starlette's behavior, GET routes now automatically respond to HEAD requests. HEAD returns the same headers as GET but with an empty body. Changes: - Add HEAD to methods set when GET is present in APIRoute - Skip auto-added HEAD (when paired with GET) in OpenAPI schema generation - Update generate_unique_id to use deterministic method selection - Add comprehensive tests for HEAD method support This allows HEAD requests to work out of the box for cache validation and resource checks, without requiring developers to define explicit HEAD routes. Explicit HEAD routes still work when defined before GET routes. Fixes #1773 --- fastapi/openapi/utils.py | 5 ++ fastapi/routing.py | 3 + fastapi/utils.py | 7 ++- tests/test_extra_routes.py | 13 +++-- tests/test_head_method.py | 115 +++++++++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 tests/test_head_method.py diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 75ff261025..2dcf3a87a9 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -278,6 +278,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 9ca2f46732..2a17edbd1d 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -549,6 +549,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 78fdcbb5b4..183e228153 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -124,7 +124,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 45734ec28a..4a4c3b66ad 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" + )