]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
🐛 Fix support for tagged union with discriminator inside of `Annotated` with `Body...
authorSebastián Ramírez <tiangolo@gmail.com>
Fri, 12 Dec 2025 14:31:21 +0000 (06:31 -0800)
committerGitHub <noreply@github.com>
Fri, 12 Dec 2025 14:31:21 +0000 (15:31 +0100)
fastapi/_compat/v2.py
tests/test_union_body_discriminator_annotated.py [new file with mode: 0644]

index 46a30b3ee80d4d1f0c1385a8fe2f59a2cdda25a1..eb5c06edd9e54b3907fbe765d92c03f7f47c77bd 100644 (file)
@@ -18,7 +18,7 @@ from typing import (
 from fastapi._compat import may_v1, shared
 from fastapi.openapi.constants import REF_TEMPLATE
 from fastapi.types import IncEx, ModelNameMap, UnionType
-from pydantic import BaseModel, ConfigDict, TypeAdapter, create_model
+from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, create_model
 from pydantic import PydanticSchemaGenerationError as PydanticSchemaGenerationError
 from pydantic import PydanticUndefinedAnnotation as PydanticUndefinedAnnotation
 from pydantic import ValidationError as ValidationError
@@ -50,6 +50,45 @@ UndefinedType = PydanticUndefinedType
 evaluate_forwardref = eval_type_lenient
 Validator = Any
 
+# TODO: remove when dropping support for Pydantic < v2.12.3
+_Attrs = {
+    "default": ...,
+    "default_factory": None,
+    "alias": None,
+    "alias_priority": None,
+    "validation_alias": None,
+    "serialization_alias": None,
+    "title": None,
+    "field_title_generator": None,
+    "description": None,
+    "examples": None,
+    "exclude": None,
+    "exclude_if": None,
+    "discriminator": None,
+    "deprecated": None,
+    "json_schema_extra": None,
+    "frozen": None,
+    "validate_default": None,
+    "repr": True,
+    "init": None,
+    "init_var": None,
+    "kw_only": None,
+}
+
+
+# TODO: remove when dropping support for Pydantic < v2.12.3
+def asdict(field_info: FieldInfo) -> Dict[str, Any]:
+    attributes = {}
+    for attr in _Attrs:
+        value = getattr(field_info, attr, Undefined)
+        if value is not Undefined:
+            attributes[attr] = value
+    return {
+        "annotation": field_info.annotation,
+        "metadata": field_info.metadata,
+        "attributes": attributes,
+    }
+
 
 class BaseConfig:
     pass
@@ -95,10 +134,15 @@ class ModelField:
                 warnings.simplefilter(
                     "ignore", category=UnsupportedFieldAttributeWarning
                 )
+            # TODO: remove after dropping support for Python 3.8 and
+            # setting the min Pydantic to v2.12.3 that adds asdict()
+            field_dict = asdict(self.field_info)
             annotated_args = (
-                self.field_info.annotation,
-                *self.field_info.metadata,
-                self.field_info,
+                field_dict["annotation"],
+                *field_dict["metadata"],
+                # this FieldInfo needs to be created again so that it doesn't include
+                # the old field info metadata and only the rest of the attributes
+                Field(**field_dict["attributes"]),
             )
             self._type_adapter: TypeAdapter[Any] = TypeAdapter(
                 Annotated[annotated_args],
diff --git a/tests/test_union_body_discriminator_annotated.py b/tests/test_union_body_discriminator_annotated.py
new file mode 100644 (file)
index 0000000..14145e6
--- /dev/null
@@ -0,0 +1,207 @@
+# Ref: https://github.com/fastapi/fastapi/discussions/14495
+
+from typing import Union
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from inline_snapshot import snapshot
+from pydantic import BaseModel
+from typing_extensions import Annotated
+
+from .utils import needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def client_fixture() -> TestClient:
+    from fastapi import Body
+    from pydantic import Discriminator, Tag
+
+    class Cat(BaseModel):
+        pet_type: str = "cat"
+        meows: int
+
+    class Dog(BaseModel):
+        pet_type: str = "dog"
+        barks: float
+
+    def get_pet_type(v):
+        assert isinstance(v, dict)
+        return v.get("pet_type", "")
+
+    Pet = Annotated[
+        Union[Annotated[Cat, Tag("cat")], Annotated[Dog, Tag("dog")]],
+        Discriminator(get_pet_type),
+    ]
+
+    app = FastAPI()
+
+    @app.post("/pet/assignment")
+    async def create_pet_assignment(pet: Pet = Body()):
+        return pet
+
+    @app.post("/pet/annotated")
+    async def create_pet_annotated(pet: Annotated[Pet, Body()]):
+        return pet
+
+    client = TestClient(app)
+    return client
+
+
+@needs_pydanticv2
+def test_union_body_discriminator_assignment(client: TestClient) -> None:
+    response = client.post("/pet/assignment", json={"pet_type": "cat", "meows": 5})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"pet_type": "cat", "meows": 5}
+
+
+@needs_pydanticv2
+def test_union_body_discriminator_annotated(client: TestClient) -> None:
+    response = client.post("/pet/annotated", json={"pet_type": "dog", "barks": 3.5})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"pet_type": "dog", "barks": 3.5}
+
+
+@needs_pydanticv2
+def test_openapi_schema(client: TestClient) -> None:
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == snapshot(
+        {
+            "openapi": "3.1.0",
+            "info": {"title": "FastAPI", "version": "0.1.0"},
+            "paths": {
+                "/pet/assignment": {
+                    "post": {
+                        "summary": "Create Pet Assignment",
+                        "operationId": "create_pet_assignment_pet_assignment_post",
+                        "requestBody": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "anyOf": [
+                                            {"$ref": "#/components/schemas/Cat"},
+                                            {"$ref": "#/components/schemas/Dog"},
+                                        ],
+                                        "title": "Pet",
+                                    }
+                                }
+                            },
+                            "required": True,
+                        },
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    }
+                },
+                "/pet/annotated": {
+                    "post": {
+                        "summary": "Create Pet Annotated",
+                        "operationId": "create_pet_annotated_pet_annotated_post",
+                        "requestBody": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "oneOf": [
+                                            {"$ref": "#/components/schemas/Cat"},
+                                            {"$ref": "#/components/schemas/Dog"},
+                                        ],
+                                        "title": "Pet",
+                                    }
+                                }
+                            },
+                            "required": True,
+                        },
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    }
+                },
+            },
+            "components": {
+                "schemas": {
+                    "Cat": {
+                        "properties": {
+                            "pet_type": {
+                                "type": "string",
+                                "title": "Pet Type",
+                                "default": "cat",
+                            },
+                            "meows": {"type": "integer", "title": "Meows"},
+                        },
+                        "type": "object",
+                        "required": ["meows"],
+                        "title": "Cat",
+                    },
+                    "Dog": {
+                        "properties": {
+                            "pet_type": {
+                                "type": "string",
+                                "title": "Pet Type",
+                                "default": "dog",
+                            },
+                            "barks": {"type": "number", "title": "Barks"},
+                        },
+                        "type": "object",
+                        "required": ["barks"],
+                        "title": "Dog",
+                    },
+                    "HTTPValidationError": {
+                        "properties": {
+                            "detail": {
+                                "items": {
+                                    "$ref": "#/components/schemas/ValidationError"
+                                },
+                                "type": "array",
+                                "title": "Detail",
+                            }
+                        },
+                        "type": "object",
+                        "title": "HTTPValidationError",
+                    },
+                    "ValidationError": {
+                        "properties": {
+                            "loc": {
+                                "items": {
+                                    "anyOf": [{"type": "string"}, {"type": "integer"}]
+                                },
+                                "type": "array",
+                                "title": "Location",
+                            },
+                            "msg": {"type": "string", "title": "Message"},
+                            "type": {"type": "string", "title": "Error Type"},
+                        },
+                        "type": "object",
+                        "required": ["loc", "msg", "type"],
+                        "title": "ValidationError",
+                    },
+                }
+            },
+        }
+    )