Browse Source

feat: automatically support HEAD method for all GET routes

Mirrors Starlette behaviour (Route.__init__ adds HEAD whenever GET is
present). HEAD requests on GET routes now return 200 with the correct
headers and an empty body, instead of 405 Method Not Allowed.

- APIRouter.add_api_route: auto-adds HEAD to route.methods when GET is
  present; revokes it if an explicit @app.head() is later registered on
  the same path so explicit handlers always take priority.
- fastapi/openapi/utils.py: skip HEAD in OpenAPI schema generation when
  GET is also present — HEAD is implied by GET (RFC 9110) and is not a
  separately documented OpenAPI operation.
- fastapi/utils.py: generate_unique_id excludes the auto-implied HEAD
  from the operationId suffix so @app.get routes keep _get.
- tests/test_head_auto.py: 8 tests covering routing, empty body,
  header parity, 405 on non-GET routes, and OpenAPI schema exclusion.

Fixes #1773
pull/15371/head
Janshi Bhavsar 3 months ago
parent
commit
ff57f7b0e1
  1. 5
      fastapi/openapi/utils.py
  2. 18
      fastapi/routing.py
  3. 9
      fastapi/utils.py
  4. 100
      tests/test_head_auto.py

5
fastapi/openapi/utils.py

@ -279,6 +279,11 @@ def get_openapi_path(
route_response_media_type: str | None = current_response_class.media_type
if route.include_in_schema:
for method in route.methods:
# HEAD is implied by GET per HTTP spec (RFC 9110); it is auto-added
# to self.methods for routing, but should not appear as a separate
# operation in the OpenAPI schema.
if method.upper() == "HEAD" and "GET" in route.methods:
continue
operation = get_openapi_operation_metadata(
route=route, method=method, operation_ids=operation_ids
)

18
fastapi/routing.py

@ -890,6 +890,7 @@ class APIRoute(routing.Route):
if methods is None:
methods = ["GET"]
self.methods: set[str] = {method.upper() for method in methods}
self._auto_head: bool = False
if isinstance(generate_unique_id_function, DefaultPlaceholder):
current_generate_unique_id: Callable[[APIRoute], str] = (
generate_unique_id_function.value
@ -1414,6 +1415,23 @@ class APIRouter(routing.Router):
strict_content_type, self.strict_content_type
),
)
# Auto-add HEAD for GET routes (mirrors Starlette behaviour, RFC 9110).
# If an explicit HEAD-only route is being registered, remove any
# auto-added HEAD from existing GET routes on the same path so the
# explicit handler takes priority.
if "GET" in route.methods and "HEAD" not in route.methods:
route.methods.add("HEAD")
route._auto_head = True
elif "HEAD" in route.methods and "GET" not in route.methods:
# Explicit HEAD route: revoke auto-HEAD on any GET sibling routes.
for existing in self.routes:
if (
isinstance(existing, APIRoute)
and existing.path == route.path
and getattr(existing, "_auto_head", False)
):
existing.methods.discard("HEAD")
existing._auto_head = False
self.routes.append(route)
def api_route(

9
fastapi/utils.py

@ -96,7 +96,14 @@ 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()}"
# Exclude auto-implied HEAD from the unique_id; HEAD is not a separately
# documented operation and should not influence the operation ID.
methods = (
route.methods - {"HEAD"}
if len(route.methods) > 1 and "HEAD" in route.methods
else route.methods
)
operation_id = f"{operation_id}_{list(methods)[0].lower()}"
return operation_id

100
tests/test_head_auto.py

@ -0,0 +1,100 @@
"""
Tests for automatic HEAD method support on GET routes (issue #1773).
FastAPI should automatically handle HEAD requests on any route that supports
GET, mirroring Starlette's behaviour (starlette/routing.py Route.__init__).
The HEAD method should be supported at the routing level but must NOT appear
as a separate operation in the OpenAPI schema.
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/items/{item_id}")
def get_item(item_id: str):
return {"item_id": item_id}
@app.get("/ping")
def ping():
return {"status": "ok"}
@app.post("/submit")
def submit():
return {"result": "created"}
client = TestClient(app)
# ---------------------------------------------------------------------------
# Routing: HEAD should succeed for GET routes
# ---------------------------------------------------------------------------
def test_head_on_get_route_returns_200():
response = client.head("/items/foo")
assert response.status_code == 200, response.text
def test_head_on_get_route_returns_empty_body():
"""HEAD responses must have no body."""
response = client.head("/items/foo")
assert response.content == b""
def test_head_response_has_same_headers_as_get():
"""Content-Type set on GET should also be present on HEAD."""
get_response = client.get("/ping")
head_response = client.head("/ping")
assert head_response.status_code == 200
assert head_response.headers.get("content-type") == get_response.headers.get(
"content-type"
)
def test_post_route_still_works():
"""POST on the submit route must still work normally."""
response = client.post("/submit")
assert response.status_code == 200
assert response.json() == {"result": "created"}
def test_head_on_post_only_route_returns_405():
"""Routes without GET must still return 405 for HEAD."""
response = client.head("/submit")
assert response.status_code == 405
def test_get_still_works_after_head_support():
"""Adding HEAD must not break GET."""
response = client.get("/items/bar")
assert response.status_code == 200
assert response.json() == {"item_id": "bar"}
# ---------------------------------------------------------------------------
# OpenAPI: HEAD must NOT appear as a separate operation in the schema
# ---------------------------------------------------------------------------
def test_head_not_in_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200
schema = response.json()
for path, path_item in schema.get("paths", {}).items():
assert "head" not in path_item, (
f"HEAD should not be a documented OpenAPI operation, "
f"but found it at path '{path}'"
)
def test_get_present_in_openapi_schema():
response = client.get("/openapi.json")
schema = response.json()
assert "get" in schema["paths"]["/items/{item_id}"]
assert "get" in schema["paths"]["/ping"]
Loading…
Cancel
Save