From: Sebastián Ramírez Date: Fri, 12 Dec 2025 14:31:21 +0000 (-0800) Subject: 🐛 Fix support for tagged union with discriminator inside of `Annotated` with `Body... X-Git-Tag: 0.124.3~2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=c0556ac3a50420d0ba298b5641517c61f3c1c2c4;p=thirdparty%2Ffastapi%2Ffastapi.git 🐛 Fix support for tagged union with discriminator inside of `Annotated` with `Body()` (#14512) --- diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 46a30b3ee8..eb5c06edd9 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -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 index 0000000000..14145e6f60 --- /dev/null +++ b/tests/test_union_body_discriminator_annotated.py @@ -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", + }, + } + }, + } + )