Browse Source
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 #1773pull/15371/head
4 changed files with 131 additions and 1 deletions
@ -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…
Reference in new issue