litestar-org / litestar

Production-ready, Light, Flexible and Extensible ASGI API framework | Effortlessly Build Performant APIs
https://litestar.dev/
MIT License
5.06k stars 347 forks source link

Bug(SQLAlchemy Plugin): Generic responses are not getting serialized #3553

Open Alc-Alc opened 3 weeks ago

Alc-Alc commented 3 weeks ago

Description

If the return type of a route has more than one field which is generic on one or two (possibly more?) types the expected serialization does not occur.

TL;DR Current behavior: Serialization Fails for all routes except route-0 Expected behavior: Serialization Success

URL to code causing the issue

No response

MCVE

from dataclasses import dataclass
from typing import Generic, TypeVar
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from litestar import Response, get
from litestar.contrib.sqlalchemy.plugins import SQLAlchemySerializationPlugin
from litestar.testing import create_test_client

class Base(DeclarativeBase):
    pass

class SomeTable(Base):
    __tablename__ = "something"
    id: Mapped[int] = mapped_column(primary_key=True)
    col: Mapped[str]

T = TypeVar("T")
U = TypeVar("U")

@dataclass
class GenericOnOne(Generic[T]):
    val_1: list[T]

@dataclass
class GenericOnTwo(Generic[T, U]):
    val_1: list[T]
    val_2: list[U]

@dataclass
class GenericOnOneButTwoFields(Generic[T]):
    val_1: list[T]
    val_2: list[T]

t = [SomeTable(id=1, col="Hello, World!")]

@get("route-0")
async def route_0() -> GenericOnOne[SomeTable]:
    # nothing wrong here, just to show it works
    return GenericOnOne(val_1=t)

@get("route-1")
async def route_1() -> GenericOnTwo[SomeTable, SomeTable]:
    # fails to serialize if it has two fields generic on different types
    return GenericOnTwo(val_1=t, val_2=t)

@get("route-2")
async def route_2() -> GenericOnOneButTwoFields[SomeTable]:
    # fails to serialize if has two fields generic on the same types
    return GenericOnOneButTwoFields(val_1=t, val_2=t)

@get("route-3")
async def route_3() -> Response[GenericOnOne[SomeTable]]:
    # fails to serialize when wrapped with another Generic
    return Response(GenericOnOne(val_1=t))

with create_test_client(
    [route_0, route_1, route_2, route_3], plugins=[SQLAlchemySerializationPlugin()]
) as client:
    response_0 = client.get("route-0")
    assert response_0.status_code == 200

    response_1 = client.get("route-1")
    assert response_1.status_code == 500

    response_2 = client.get("route-2")
    assert response_2.status_code == 500

    response_3 = client.get("route-3")
    assert response_3.status_code == 500

Steps to reproduce

Run the code

Screenshots

"![SCREENSHOT_DESCRIPTION](SCREENSHOT_LINK.png)"

Logs

Traceback (most recent call last):
  File "litestar/litestar/serialization/msgspec_hooks.py", line 162, in encode_json
    return msgspec.json.encode(value, enc_hook=serializer) if serializer else _msgspec_json_encoder.encode(value)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar/litestar/serialization/msgspec_hooks.py", line 92, in default_serializer
    raise TypeError(f"Unsupported type: {type(value)!r}")
TypeError: Unsupported type: <class '__main__.SomeTable'>

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "litestar/litestar/middleware/_internal/exceptions/middleware.py", line 158, in __call__
    await self.app(scope, receive, capture_response_started)
  File "litestar/litestar/_asgi/asgi_router.py", line 99, in __call__
    await asgi_app(scope, receive, send)
  File "litestar/litestar/routes/http.py", line 80, in handle
    response = await self._get_response_for_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar/litestar/routes/http.py", line 132, in _get_response_for_request
    return await self._call_handler_function(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar/litestar/routes/http.py", line 156, in _call_handler_function
    response: ASGIApp = await route_handler.to_response(app=scope["app"], data=response_data, request=request)
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar/litestar/handlers/http_handlers/base.py", line 557, in to_response
    return await response_handler(app=app, data=data, request=request)  # type: ignore[call-arg]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar/litestar/handlers/http_handlers/_utils.py", line 79, in handler
    return response.to_asgi_response(app=None, request=request, headers=normalize_headers(headers), cookies=cookies)  # pyright: ignore
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar/litestar/response/base.py", line 451, in to_asgi_response
    body=self.render(self.content, media_type, get_serializer(type_encoders)),
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar/litestar/response/base.py", line 392, in render
    return encode_json(content, enc_hook)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "litestar/litestar/serialization/msgspec_hooks.py", line 164, in encode_json
    raise SerializationException(str(msgspec_error)) from msgspec_error
litestar.exceptions.base_exceptions.SerializationException: Unsupported type: <class '__main__.SomeTable'>

Litestar Version

84f51c8afc3203cd4914922b2ec3c1e92d5d40ba (main as of issue creation)

Platform


[!NOTE]
While we are open for sponsoring on GitHub Sponsors and OpenCollective, we also utilize Polar.sh to engage in pledge-based sponsorship.

Check out all issues funded or available for funding on our Polar.sh dashboard

  • If you would like to see an issue prioritized, make a pledge towards it!
  • We receive the pledge once the issue is completed & verified
  • This, along with engagement in the community, helps us know which features are a priority to our users.

Fund with Polar

EliasEriksson commented 2 weeks ago

Another MCVE that seams to be causing this issue as discussed on discord (an example from the docs wrapped with litestar.Response):

MVCE

import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Generic, TypeVar
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy import text
from sqlalchemy import Uuid
from sqlalchemy.orm import DeclarativeBase
from litestar import Litestar, get
from litestar import Response
from litestar.contrib.sqlalchemy.dto import SQLAlchemyDTO
from litestar.dto import DTOConfig

T = TypeVar("T")

class Base(DeclarativeBase):
    pass

@dataclass
class WithCount(Generic[T]):
    count: int
    data: list[T]

class User(Base):
    __tablename__ = "user"
    id: Mapped[uuid.UUID] = mapped_column(
        Uuid(as_uuid=True, native_uuid=True),
        primary_key=True,
        nullable=False,
        server_default=text("gen_random_uuid()"),
    )
    name: Mapped[str]
    password: Mapped[str]
    created_at: Mapped[datetime]

class UserDTO(SQLAlchemyDTO[User]):
    config = DTOConfig(exclude={"password", "created_at"})

@get("/api/users", dto=UserDTO, sync_to_thread=False)
def get_users() -> Response[WithCount[User]]:
    return Response(
        WithCount(
            count=1,
            data=[
                User(
                    id=uuid.uuid4(),
                    name="Litestar User",
                    password="xyz",
                    created_at=datetime.now(),
                ),
            ],
        )
    )

api = Litestar(route_handlers=[get_users], debug=True)

crashes when requesting GET /api/users

Logs:

INFO:     127.0.0.1:47442 - "GET /api/users HTTP/1.1" 500 Internal Server Error
ERROR - 2024-06-08 17:55:09,723 - litestar - config - Uncaught exception (connection_type=http, path=/api/users):
Traceback (most recent call last):
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/serialization/msgspec_hooks.py", line 162, in encode_json
    return msgspec.json.encode(value, enc_hook=serializer) if serializer else _msgspec_json_encoder.encode(value)
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/serialization/msgspec_hooks.py", line 92, in default_serializer
    raise TypeError(f"Unsupported type: {type(value)!r}")
TypeError: Unsupported type: <class 'api.api.User'>

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/middleware/_internal/exceptions/middleware.py", line 158, in __call__
    await self.app(scope, receive, capture_response_started)
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/_asgi/asgi_router.py", line 99, in __call__
    await asgi_app(scope, receive, send)
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/routes/http.py", line 80, in handle
    response = await self._get_response_for_request(
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/routes/http.py", line 132, in _get_response_for_request
    return await self._call_handler_function(
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/routes/http.py", line 156, in _call_handler_function
    response: ASGIApp = await route_handler.to_response(app=scope["app"], data=response_data, request=request)
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/handlers/http_handlers/base.py", line 557, in to_response
    return await response_handler(app=app, data=data, request=request)  # type: ignore[call-arg]
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/handlers/http_handlers/_utils.py", line 152, in handler
    return response.to_asgi_response(  # type: ignore[no-any-return]
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/response/base.py", line 451, in to_asgi_response
    body=self.render(self.content, media_type, get_serializer(type_encoders)),
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/response/base.py", line 392, in render
    return encode_json(content, enc_hook)
  File "/home/elias-eriksson/dev/eliaseriksson/fullstack-template/venv/lib/python3.10/site-packages/litestar/serialization/msgspec_hooks.py", line 164, in encode_json
    raise SerializationException(str(msgspec_error)) from msgspec_error
litestar.exceptions.base_exceptions.SerializationException: Unsupported type: <class 'api.api.User'>
Alc-Alc commented 2 weeks ago

Another MCVE that seams to be causing this issue as discussed on discord (an example from the docs wrapped with litestar.Response):

Thanks I have added the use case to the tests above