🐛 Fix support for tagged union with discriminator inside of `Annotated` with `Body()` (#14512)

This commit is contained in:
Sebastián Ramírez 2025-12-12 06:31:21 -08:00 committed by GitHub
parent 1fcec88ad2
commit c0556ac3a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 255 additions and 4 deletions

View File

@ -18,7 +18,7 @@ from typing import (
from fastapi._compat import may_v1, shared from fastapi._compat import may_v1, shared
from fastapi.openapi.constants import REF_TEMPLATE from fastapi.openapi.constants import REF_TEMPLATE
from fastapi.types import IncEx, ModelNameMap, UnionType 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 PydanticSchemaGenerationError as PydanticSchemaGenerationError
from pydantic import PydanticUndefinedAnnotation as PydanticUndefinedAnnotation from pydantic import PydanticUndefinedAnnotation as PydanticUndefinedAnnotation
from pydantic import ValidationError as ValidationError from pydantic import ValidationError as ValidationError
@ -50,6 +50,45 @@ UndefinedType = PydanticUndefinedType
evaluate_forwardref = eval_type_lenient evaluate_forwardref = eval_type_lenient
Validator = Any 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: class BaseConfig:
pass pass
@ -95,10 +134,15 @@ class ModelField:
warnings.simplefilter( warnings.simplefilter(
"ignore", category=UnsupportedFieldAttributeWarning "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 = ( annotated_args = (
self.field_info.annotation, field_dict["annotation"],
*self.field_info.metadata, *field_dict["metadata"],
self.field_info, # 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( self._type_adapter: TypeAdapter[Any] = TypeAdapter(
Annotated[annotated_args], Annotated[annotated_args],

View File

@ -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",
},
}
},
}
)