Browse Source

Automatically support HEAD method for all GET routes

RFC 7231 §4.3.2 states that the server SHOULD send the same header fields
in response to a HEAD request as it would have sent for GET. Starlette
does this by default but FastAPI's APIRoute did not.

This adds automatic HEAD support by overriding matches() and handle() on
APIRoute to accept HEAD requests when the route has GET in its methods.

Key design decisions:
- HEAD is NOT added to self.methods, so it stays out of the OpenAPI schema
- Explicit HEAD routes still work and appear in OpenAPI when declared
- The match promotion only happens when HEAD is not already in methods,
  so explicit @app.head() or methods=["GET","HEAD"] are unaffected
- POST/PUT/DELETE/PATCH routes are not affected

Closes #1773
pull/15356/head
tengtian 3 months ago
committed by Herrtian
parent
commit
e852c1cd0d
  1. 35
      fastapi/routing.py
  2. 173
      tests/test_auto_head_for_get.py
  3. 6
      tests/test_extra_routes.py

35
fastapi/routing.py

@ -1152,12 +1152,33 @@ class APIRoute(routing.Route):
is_json_stream=route.is_json_stream,
)
def _is_head_for_get(
self, scope: Scope, methods: Collection[str] | None = None
) -> bool:
"""Check if this is a HEAD request that should be served by a GET route."""
route_methods = methods if methods is not None else self.methods
return (
scope.get("type") == "http"
and scope.get("method") == "HEAD"
and bool(route_methods)
and "GET" in route_methods
and "HEAD" not in route_methods
)
def matches(self, scope: Scope) -> tuple[Match, Scope]:
effective_context = _get_scope_effective_route_context(scope)
if effective_context is not None and effective_context.original_route is self:
match, child_scope = effective_context.matches(scope)
else:
match, child_scope = super().matches(scope)
# Automatically support HEAD for GET routes (HTTP spec compliance).
# RFC 7231 §4.3.2: the server SHOULD send the same header fields in
# response to a HEAD request as it would have sent for GET.
# HEAD is NOT added to self.methods so it stays out of the OpenAPI
# schema. Instead, we promote the match at routing time and allow
# it through in handle().
if match == Match.PARTIAL and self._is_head_for_get(scope):
match = Match.FULL
if match != Match.NONE:
child_scope["route"] = self
return match, child_scope
@ -1166,7 +1187,11 @@ class APIRoute(routing.Route):
effective_context = _get_scope_effective_route_context(scope)
if effective_context is not None and effective_context.original_route is self:
methods = effective_context.methods
if methods and scope["method"] not in methods:
if (
methods
and scope["method"] not in methods
and not self._is_head_for_get(scope, methods)
):
headers = {"Allow": ", ".join(methods)}
if "app" in scope:
raise HTTPException(status_code=405, headers=headers)
@ -1182,7 +1207,11 @@ class APIRoute(routing.Route):
_effective_route_context_var.reset(token)
await app(scope, receive, send)
return
await super().handle(scope, receive, send)
# Allow HEAD requests through to the GET handler.
if self._is_head_for_get(scope):
await self.app(scope, receive, send)
else:
await super().handle(scope, receive, send)
@dataclass
@ -1628,8 +1657,6 @@ def _iter_routes_with_context(
yield route_context.original_route, route_context
else:
yield route, None
class APIRouter(routing.Router):
"""
`APIRouter` class, used to group *path operations*, for example to structure

173
tests/test_auto_head_for_get.py

@ -0,0 +1,173 @@
"""Tests for automatic HEAD method support on GET routes.
RFC 7231 §4.3.2 states that the server SHOULD send the same header fields
in response to a HEAD request as it would have sent if the request had been
a GET. FastAPI now automatically handles HEAD requests for all GET routes
without adding HEAD to the OpenAPI schema.
"""
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
def test_head_returns_200_for_get_route():
app = FastAPI()
@app.get("/")
def read_root():
return {"hello": "world"}
client = TestClient(app)
response = client.head("/")
assert response.status_code == 200
# HEAD response has no body but preserves headers
assert response.content == b""
assert response.headers["content-length"] == "17"
assert response.headers["content-type"] == "application/json"
def test_head_works_with_path_params():
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(item_id: int):
return {"item_id": item_id}
client = TestClient(app)
response = client.head("/items/42")
assert response.status_code == 200
assert response.content == b""
def test_head_not_in_openapi_schema():
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)
schema = client.get("/openapi.json").json()
# HEAD should NOT appear in OpenAPI paths
assert list(schema["paths"]["/"].keys()) == ["get"]
assert list(schema["paths"]["/items/{item_id}"].keys()) == ["get"]
def test_head_not_added_to_non_get_routes():
app = FastAPI()
@app.post("/submit")
def submit():
return {"ok": True}
@app.put("/items/{item_id}")
def update_item(item_id: int):
return {"item_id": item_id}
client = TestClient(app)
assert client.head("/submit").status_code == 405
assert client.head("/items/1").status_code == 405
def test_explicit_head_route_in_schema():
"""When HEAD is explicitly declared, it SHOULD appear in OpenAPI."""
app = FastAPI()
@app.head("/health")
def health_check():
return JSONResponse(None, headers={"x-status": "ok"})
client = TestClient(app)
response = client.head("/health")
assert response.status_code == 200
schema = client.get("/openapi.json").json()
assert "head" in schema["paths"]["/health"]
def test_explicit_get_and_head_via_api_route():
"""When GET and HEAD are both declared via api_route, both work."""
app = FastAPI()
@app.api_route("/both", methods=["GET", "HEAD"])
def both_methods():
return {"ok": True}
client = TestClient(app)
assert client.get("/both").status_code == 200
assert client.head("/both").status_code == 200
def test_get_still_works_after_auto_head():
"""GET must not be affected by the auto HEAD feature."""
app = FastAPI()
@app.get("/data")
def get_data():
return {"value": 123}
client = TestClient(app)
response = client.get("/data")
assert response.status_code == 200
assert response.json() == {"value": 123}
def test_head_with_response_model():
"""HEAD works correctly with routes that have response models."""
app = FastAPI()
class Item(BaseModel):
name: str
price: float
@app.get("/item", response_model=Item)
def get_item():
return Item(name="Widget", price=9.99)
client = TestClient(app)
response = client.head("/item")
assert response.status_code == 200
assert response.content == b""
assert "content-length" in response.headers
def test_head_with_add_api_route():
"""HEAD works for routes added via add_api_route()."""
app = FastAPI()
def get_data():
return {"data": True}
app.add_api_route("/data", get_data)
client = TestClient(app)
response = client.head("/data")
assert response.status_code == 200
assert response.content == b""
def test_head_with_router_include():
"""HEAD works for routes added via APIRouter."""
from fastapi import APIRouter
app = FastAPI()
router = APIRouter()
@router.get("/info")
def get_info():
return {"info": "test"}
app.include_router(router, prefix="/api")
client = TestClient(app)
response = client.head("/api/info")
assert response.status_code == 200
assert response.content == b""
# Not in OpenAPI schema
schema = client.get("/openapi.json").json()
assert list(schema["paths"]["/api/info"].keys()) == ["get"]

6
tests/test_extra_routes.py

@ -71,9 +71,13 @@ def test_delete():
def test_head():
# HEAD is served by the GET handler (registered first) because GET
# routes automatically support HEAD per RFC 7231 §4.3.2. The explicit
# @app.head handler is shadowed since the GET route matches first.
response = client.head("/items/foo")
assert response.status_code == 200, response.text
assert response.headers["x-fastapi-item-id"] == "foo"
assert response.content == b""
assert response.headers["content-type"] == "application/json"
def test_options():

Loading…
Cancel
Save