mirror of https://github.com/tiangolo/fastapi.git
Merge d201fdcdfa into cc6ced6345
This commit is contained in:
commit
2ed38cbe65
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
Loading…
Reference in New Issue