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
This commit is contained in:
Jonathan Fulton 2026-01-31 18:54:27 -05:00
parent 08924400c2
commit d201fdcdfa
5 changed files with 137 additions and 6 deletions

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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})

115
tests/test_head_method.py Normal file
View File

@ -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"
)