diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 40dffba64..9ff94f4a2 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -401,8 +401,11 @@ def analyze_param( depends = None type_annotation: Any = Any use_annotation: Any = Any + type_alias_annotation = None if is_typealiastype(annotation): - # unpack in case PEP 695 type syntax is used + # Unpack PEP 695 type aliases for the analysis below, but keep the original + # alias so an inferred body can still produce a named OpenAPI component. + type_alias_annotation = annotation annotation = annotation.__value__ if annotation is not inspect.Signature.empty: use_annotation = annotation @@ -512,6 +515,10 @@ def analyze_param( ) or is_uploadfile_sequence_annotation(type_annotation): field_info = params.File(annotation=use_annotation, default=default_value) elif not field_annotation_is_scalar(annotation=type_annotation): + if type_alias_annotation is not None: + # Use the original PEP 695 alias as the body annotation so it yields a + # named OpenAPI component ($ref), like `Annotated[Alias, Body()]` (#15855). + use_annotation = type_alias_annotation field_info = params.Body(annotation=use_annotation, default=default_value) else: field_info = params.Query(annotation=use_annotation, default=default_value) diff --git a/tests/test_dependency_pep695.py b/tests/test_dependency_pep695.py index ef5636638..d765a38bb 100644 --- a/tests/test_dependency_pep695.py +++ b/tests/test_dependency_pep695.py @@ -1,7 +1,8 @@ -from typing import Annotated +from typing import Annotated, Literal -from fastapi import Depends, FastAPI +from fastapi import Body, Depends, FastAPI from fastapi.testclient import TestClient +from pydantic import BaseModel, Field from typing_extensions import TypeAliasType @@ -25,3 +26,67 @@ def test_pep695_type_dependencies(): response = client.get("/") assert response.status_code == 200 assert response.text == '"value: 123"' + + +def test_pep695_type_alias_body_keeps_named_ref(): + """A bare PEP 695 ``TypeAliasType`` used as a body parameter should generate + the same named OpenAPI component (``$ref``) as the equivalent + ``Annotated[Alias, Body()]`` form. + + Regression test for https://github.com/fastapi/fastapi/issues/15855 + """ + app = FastAPI() + + class Cat(BaseModel): + pet_type: Literal["cat"] + meows: int + + class Dog(BaseModel): + pet_type: Literal["dog"] + barks: float + + Pet = TypeAliasType( + "Pet", + Annotated[Cat | Dog, Field(discriminator="pet_type")], + type_params=(), + ) + + @app.post("/bare") + def bare(pet: Pet): + return pet + + @app.post("/body") + def body(pet: Annotated[Pet, Body()]): + return pet + + client = TestClient(app) + schema = client.get("/openapi.json").json() + + def request_body_schema(path: str): + return schema["paths"][path]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] + + expected_ref = {"$ref": "#/components/schemas/Pet"} + # The explicit Annotated[..., Body()] form already keeps the named ref today. + assert request_body_schema("/body") == expected_ref + # The bare alias form must produce the same named ref (was inlined before the fix). + assert request_body_schema("/bare") == expected_ref + assert "Pet" in schema["components"]["schemas"] + + # The discriminated union still validates and serializes correctly. + response = client.post("/bare", json={"pet_type": "cat", "meows": 3}) + assert response.status_code == 200, response.text + assert response.json() == {"pet_type": "cat", "meows": 3} + + response = client.post("/bare", json={"pet_type": "dog", "barks": 1.5}) + assert response.status_code == 200, response.text + assert response.json() == {"pet_type": "dog", "barks": 1.5} + + response = client.post("/bare", json={"pet_type": "lizard"}) + assert response.status_code == 422, response.text + + # The explicit Annotated[..., Body()] form behaves identically. + response = client.post("/body", json={"pet_type": "dog", "barks": 1.5}) + assert response.status_code == 200, response.text + assert response.json() == {"pet_type": "dog", "barks": 1.5}