diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 8f1852b0cc..57d6d5d3d4 100644 --- a/fastapi/openapi/utils.py +++ b/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 ) diff --git a/fastapi/routing.py b/fastapi/routing.py index 36acb6b89d..ee5452136d 100644 --- a/fastapi/routing.py +++ b/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( diff --git a/fastapi/utils.py b/fastapi/utils.py index 12eaa2bf08..9bdee3ed33 100644 --- a/fastapi/utils.py +++ b/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 diff --git a/tests/test_head_auto.py b/tests/test_head_auto.py new file mode 100644 index 0000000000..40753f6481 --- /dev/null +++ b/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"]