diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 7be686d865..fd59824625 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -392,14 +392,28 @@ def create_body_model( return BodyModel +def _needs_no_config(type_: Any) -> bool: + """Return True if TypeAdapter must not receive config= for this type. + + Pydantic raises PydanticUserError when config= is passed to TypeAdapter and + the resolved type (or its inner type) is a BaseModel, dataclass, or TypedDict. + We therefore suppress config= not only for bare BaseModel/dataclass types but + also for Annotated wrappers whose first argument is such a type — this covers + Json[BaseModel], Json[SerializeAsAny[BaseModel]], and plain + Annotated[BaseModel, ...] forms. + """ + if lenient_issubclass(type_, (BaseModel, dict)) or is_dataclass(type_): + return True + if get_origin(type_) is Annotated: + return _needs_no_config(get_args(type_)[0]) + return False + + def get_model_fields(model: type[BaseModel]) -> list[ModelField]: model_fields: list[ModelField] = [] for name, field_info in model.model_fields.items(): type_ = field_info.annotation - if lenient_issubclass(type_, (BaseModel, dict)) or is_dataclass(type_): - model_config = None - else: - model_config = model.model_config + model_config = None if _needs_no_config(type_) else model.model_config model_fields.append( ModelField( field_info=field_info, diff --git a/tests/test_json_field_serialize_as_any.py b/tests/test_json_field_serialize_as_any.py new file mode 100644 index 0000000000..b1fcac45a5 --- /dev/null +++ b/tests/test_json_field_serialize_as_any.py @@ -0,0 +1,66 @@ +""" +Regression tests for: + PydanticUserError when a generic BaseModel with a Json[T] field is + specialized with SerializeAsAny[SomeModel]. + + fastapi/_compat/v2.py: get_model_fields() must suppress config= for + Annotated-wrapped types whose inner type is a BaseModel / dataclass, + not only for bare BaseModel types. +""" + +from typing import Generic, TypeVar + +from fastapi import FastAPI +from pydantic import BaseModel, Json, SerializeAsAny + +T = TypeVar("T") + + +class Inner(BaseModel): + value: str + + +class Source(BaseModel, Generic[T]): + payload: Json[T] + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_openapi_schema_generation_serialize_as_any_does_not_raise(): + """app.openapi() must not raise PydanticUserError for Source[SerializeAsAny[Inner]]. + + Previously raised: + pydantic.errors.PydanticUserError: Cannot use `config` when the type + is a BaseModel, dataclass or TypedDict. + + Root cause: get_model_fields() in fastapi/_compat/v2.py did not unwrap + Annotated before checking if config= should be suppressed, so + Json[Annotated[Inner, SerializeAsAny]] slipped through and TypeAdapter + received config= illegally. + """ + app = FastAPI() + + @app.get("/search") + def search() -> Source[SerializeAsAny[Inner]]: # pragma: no cover + ... + + schema = app.openapi() + assert schema is not None + # The route must appear in the generated schema. + assert "/search" in schema["paths"] + + +def test_openapi_schema_generation_plain_inner_unaffected(): + """Baseline: Source[Inner] (no SerializeAsAny) must continue to work.""" + app = FastAPI() + + @app.get("/search") + def search() -> Source[Inner]: # pragma: no cover + ... + + schema = app.openapi() + assert schema is not None + assert "/search" in schema["paths"]