This commit is contained in:
Jonathan Fulton 2026-02-06 19:08:43 +00:00 committed by GitHub
commit 2ed38cbe65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 137 additions and 6 deletions

View File

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

View File

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

View File

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

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