Repository: litestar-org/litestar

Issue:
bug: openapi schema generation for DTO with generic wrapper
Handler such as:

```py
    @get('/all-not-working', return_dto=QuotesSQLAlchemyDTO)
    async def get_all_quotes_not_working(self, sqlite_session: AsyncSession) -> GetAllQuotesResponse[QuotesTable]:
        res = GetAllQuotesResponse(quotes=await get_all_quotes(sqlite_session))
        LOGGER.critical(f"sherlock {res}")
        return res
```

Renders such as (only generates schema for the model type, not the wrapper type):

![image](https://github.com/litestar-org/litestar/assets/20659309/63a6be3d-4e5d-455c-ab0c-97d992412f40)

Issue is that we don't pass the full annotation from the DTO to the backend, we only pass the model type that is extracted from the annotation and the name of the attribute that it exists upon on the wrapper class. So, when the DTO is called upon to generate the openapi schema for the type, it doesn't incorporate the wrapper type, only the model.

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh dashboard](https://polar.sh/litestar-org)
> * 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.

<a href="https://polar.sh/litestar-org/litestar/issues/2929">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2929/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2929/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:

Resolving patch:
diff --git a/litestar/dto/base_dto.py b/litestar/dto/base_dto.py
--- a/litestar/dto/base_dto.py
+++ b/litestar/dto/base_dto.py
@@ -195,11 +195,31 @@ def create_openapi_schema(
     ) -> Reference | Schema:
         """Create an OpenAPI request body.
 
+        Args:
+            field_definition: A parsed type annotation that represents the annotation used on the handler.
+            handler_id: ID of the route handler for which to create a DTO instance.
+            schema_creator: A factory for creating schemas. Has a ``for_field_definition()`` method that accepts a
+                :class:`~litestar.typing.FieldDefinition` instance.
+
         Returns:
             OpenAPI request body.
         """
         key = "data_backend" if field_definition.name == "data" else "return_backend"
         backend = cls._dto_backends[handler_id][key]  # type: ignore[literal-required]
+
+        if backend.wrapper_attribute_name:
+            # The DTO has been built for a handler that has a DTO supported type wrapped in a generic type.
+            #
+            # The backend doesn't receive the full annotation, only the type of the attribute on the outer type that
+            # holds the DTO supported type.
+            #
+            # This special casing rebuilds the outer generic type annotation with the original model replaced by the DTO
+            # generated transfer model type in the type arguments.
+            transfer_model = backend.transfer_model_type
+            generic_args = tuple(transfer_model if a is cls.model_type else a for a in field_definition.args)
+            return schema_creator.for_field_definition(
+                FieldDefinition.from_annotation(field_definition.origin[generic_args])
+            )
         return schema_creator.for_field_definition(FieldDefinition.from_annotation(backend.annotation))
 
     @classmethod


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,7 +9,7 @@
 from datetime import datetime
 from os import urandom
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable, Generator, TypeVar, Union, cast
+from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable, Generator, Union, cast
 from unittest.mock import AsyncMock, MagicMock
 
 import pytest
@@ -30,6 +30,7 @@
 from litestar.stores.memory import MemoryStore
 from litestar.stores.redis import RedisStore
 from litestar.testing import RequestFactory
+from tests.helpers import not_none
 
 if TYPE_CHECKING:
     from types import ModuleType
@@ -228,11 +229,6 @@ def wrapped(source: str) -> ModuleType:
         Returns:
             An imported module.
         """
-        T = TypeVar("T")
-
-        def not_none(val: T | T | None) -> T:
-            assert val is not None
-            return val
 
         def module_name_generator() -> str:
             letters = string.ascii_lowercase
diff --git a/tests/helpers.py b/tests/helpers.py
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -106,3 +106,8 @@ def cleanup_logging_impl() -> Generator:
         queue_listener_handler.listener.stop()
         queue_listener_handler.close()
         del queue_listener_handler
+
+
+def not_none(val: T | None) -> T:
+    assert val is not None
+    return val
diff --git a/tests/unit/test_dto/test_factory/test_integration.py b/tests/unit/test_dto/test_factory/test_integration.py
--- a/tests/unit/test_dto/test_factory/test_integration.py
+++ b/tests/unit/test_dto/test_factory/test_integration.py
@@ -18,14 +18,19 @@
 from litestar.dto import DataclassDTO, DTOConfig, DTOData, MsgspecDTO, dto_field
 from litestar.dto.types import RenameStrategy
 from litestar.enums import MediaType, RequestEncodingType
+from litestar.openapi.spec.response import OpenAPIResponse
+from litestar.openapi.spec.schema import Schema
 from litestar.pagination import ClassicPagination, CursorPagination, OffsetPagination
 from litestar.params import Body
 from litestar.serialization import encode_json
 from litestar.testing import create_test_client
+from tests.helpers import not_none
 
 if TYPE_CHECKING:
     from typing import Any
 
+    from litestar import Litestar
+
 
 def test_url_encoded_form_data(use_experimental_dto_backend: bool) -> None:
     @dataclass()
@@ -898,3 +903,103 @@ def handler(data: Foo) -> None:
 
     with create_test_client([handler]) as client:
         assert client.post("/", json={"bar": "1", "baz": "123"}).status_code == 201
+
+
+def test_openapi_schema_for_type_with_generic_pagination_type(
+    create_module: Callable[[str], ModuleType], use_experimental_dto_backend: bool
+) -> None:
+    module = create_module(
+        """
+from dataclasses import dataclass
+
+from litestar import Litestar, get
+from litestar.dto import DataclassDTO
+from litestar.pagination import ClassicPagination
+
+@dataclass
+class Test:
+    name: str
+    age: int
+
+@get("/without-dto", sync_to_thread=False)
+def without_dto() -> ClassicPagination[Test]:
+    return ClassicPagination(
+        items=[Test("John", 25), Test("Jane", 30)],
+        page_size=1,
+        current_page=2,
+        total_pages=2,
+    )
+
+@get("/with-dto", return_dto=DataclassDTO[Test], sync_to_thread=False)
+def with_dto() -> ClassicPagination[Test]:
+    return ClassicPagination(
+        items=[Test("John", 25), Test("Jane", 30)],
+        page_size=1,
+        current_page=2,
+        total_pages=2,
+    )
+
+app = Litestar([without_dto, with_dto])
+"""
+    )
+    openapi = cast("Litestar", module.app).openapi_schema
+    paths = not_none(openapi.paths)
+    without_dto_response = not_none(not_none(paths["/without-dto"].get).responses)["200"]
+    with_dto_response = not_none(not_none(paths["/with-dto"].get).responses)["200"]
+    assert isinstance(without_dto_response, OpenAPIResponse)
+    assert isinstance(with_dto_response, OpenAPIResponse)
+    without_dto_schema = not_none(without_dto_response.content)["application/json"].schema
+    with_dto_schema = not_none(with_dto_response.content)["application/json"].schema
+    assert isinstance(without_dto_schema, Schema)
+    assert isinstance(with_dto_schema, Schema)
+    assert not_none(without_dto_schema.properties).keys() == not_none(with_dto_schema.properties).keys()
+
+
+def test_openapi_schema_for_type_with_custom_generic_type(
+    create_module: Callable[[str], ModuleType], use_experimental_dto_backend: bool
+) -> None:
+    module = create_module(
+        """
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Generic, List, TypeVar
+
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+
+from litestar import Litestar, get
+from litestar.contrib.sqlalchemy.dto import SQLAlchemyDTO
+from litestar.dto import DTOConfig
+
+T = TypeVar("T")
+
+@dataclass
+class WithCount(Generic[T]):
+    count: int
+    data: List[T]
+
+class Base(DeclarativeBase): ...
+
+class User(Base):
+    __tablename__ = "user"
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str]
+    password: Mapped[str]
+    created_at: Mapped[datetime]
+
+class UserDTO(SQLAlchemyDTO[User]):
+    config = DTOConfig(exclude={"password", "created_at"})
+
+@get("/users", dto=UserDTO, sync_to_thread=False)
+def get_users() -> WithCount[User]:
+    return WithCount(
+        count=1, data=[User(id=1, name="Litestar User", password="xyz", created_at=datetime.now())]
+    )
+
+app = Litestar(route_handlers=[get_users])
+"""
+    )
+    openapi = cast("Litestar", module.app).openapi_schema
+    schema = openapi.components.schemas["WithCount[litestar.dto._backend.GetUsersUserResponseBody]"]
+    assert not_none(schema.properties).keys() == {"count", "data"}
+    model_schema = openapi.components.schemas["GetUsersUserResponseBody"]
+    assert not_none(model_schema.properties).keys() == {"id", "name"}