From 96758a47ae373e948ffc43fd2081ae8b268e806f Mon Sep 17 00:00:00 2001 From: Viacheslav Date: Thu, 15 Oct 2026 14:47:09 +0500 Subject: [PATCH] fix: decode cached JSON as the annotated return type Pydantic v2 removed ModelField, and the decoder was stubbed out instead of replaced, so cache hits came back as plain dicts. --- changelog.d/249.bugfix.md | 1 + fastapi_cache/coder.py | 26 +++++++++++++++----------- tests/test_codecs.py | 6 +++++- 3 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 changelog.d/249.bugfix.md diff --git a/changelog.d/249.bugfix.md b/changelog.d/249.bugfix.md new file mode 100644 index 00000000..9559428d --- /dev/null +++ b/changelog.d/249.bugfix.md @@ -0,0 +1 @@ +Decode cached JSON into the annotated return type on Pydantic v2. diff --git a/fastapi_cache/coder.py b/fastapi_cache/coder.py index 644c1821..57d448be 100644 --- a/fastapi_cache/coder.py +++ b/fastapi_cache/coder.py @@ -15,15 +15,12 @@ import pendulum from fastapi.encoders import jsonable_encoder +from pydantic import TypeAdapter from starlette.responses import JSONResponse from starlette.templating import ( _TemplateResponse as TemplateResponse, # pyright: ignore[reportPrivateUsage] ) - -class ModelField: - pass - _T = TypeVar("_T", bound=type) @@ -67,12 +64,9 @@ def encode(cls, value: Any) -> bytes: def decode(cls, value: bytes) -> Any: raise NotImplementedError - # (Shared) cache for endpoint return types to Pydantic model fields. - # Note that subclasses share this cache! If a subclass overrides the - # decode_as_type method and then stores a different kind of field for a - # given type, do make sure that the subclass provides its own class - # attribute for this cache. - _type_field_cache: ClassVar[Dict[Any, ModelField]] = {} + # Subclasses share this. Override the attribute if decode_as_type stores + # something other than a TypeAdapter for a given type. + _type_adapter_cache: ClassVar[Dict[Any, TypeAdapter[Any]]] = {} @overload @classmethod @@ -92,7 +86,17 @@ def decode_as_type(cls, value: bytes, *, type_: Optional[_T]) -> Union[_T, Any]: """ result = cls.decode(value) - return result + if type_ is None: + return result + + adapter: TypeAdapter[Any] + try: + adapter = cls._type_adapter_cache[type_] + except KeyError: + adapter = TypeAdapter(type_) + cls._type_adapter_cache[type_] = adapter + validated: Any = adapter.validate_python(result) + return validated class JsonCoder(Coder): diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 1a4ea71d..e1c8d1ed 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Optional, Tuple, Type +from typing import Any, List, Optional, Tuple, Type import pytest from pydantic import BaseModel, ValidationError @@ -51,6 +51,10 @@ def test_pickle_coder(value: Any) -> None: ({"some_key": 1, "other_key": 2}, None), (DCItem(name="foo", price=42.0, description="some dataclass item", tax=0.2), DCItem), (PDItem(name="foo", price=42.0, description="some pydantic item", tax=0.2), PDItem), + ( + [PDItem(name="foo", price=42.0, description="some pydantic item", tax=0.2)], + List[PDItem], + ), ], ) def test_json_coder(value: Any, return_type: Type[Any]) -> None: