Browse Source

Fix PydanticUserError for Json[SerializeAsAny[BaseModel]] in get_model_fields

Pydantic 2.12 raises PydanticUserError when config= is passed to TypeAdapter
and the resolved inner type is a BaseModel, dataclass, or TypedDict.

The guard in get_model_fields() only checked bare BaseModel/dataclass types
via lenient_issubclass, which does not unwrap Annotated. As a result,
Json[SerializeAsAny[Inner]] (which expands to Annotated[Inner, SerializeAsAny(),
Json] at runtime) slipped through and caused a crash in app.openapi().

Extract a _needs_no_config() helper that recursively unwraps Annotated before
checking the inner type, covering all affected forms:
- Annotated[BaseModel, ...]
- Json[BaseModel]  →  Annotated[BaseModel, Json]
- Json[SerializeAsAny[BaseModel]]  →  Annotated[BaseModel, SerializeAsAny(), Json]

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
pull/15329/head
Yuta Hayashibe 2 months ago
parent
commit
e774a05e32
Failed to extract signature
  1. 22
      fastapi/_compat/v2.py
  2. 66
      tests/test_json_field_serialize_as_any.py

22
fastapi/_compat/v2.py

@ -379,14 +379,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,

66
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"]
Loading…
Cancel
Save