Repository: litestar-org/litestar

Issue:
Bug: fail to register route with return DTO nested in dictionary
### Description

I'm trying to return an object nested inside a dictionary using DTO, but litestar failed to register the route.

Originally I was working with SQLAlchemy models, with return type `dict[str, list[Model]]` but later found out
that a simpler data types also didn't work.

### URL to code causing the issue

_No response_

### MCVE

```python
from typing import Dict

from dataclasses import dataclass
from litestar import Litestar, get
from litestar.dto import DataclassDTO

@dataclass
class Lexeme: ...


class ReadDTO(DataclassDTO[Lexeme]): ...


@get(return_dto=ReadDTO)
async def get_definition() -> Dict[str, Lexeme]:
    return {}

app = Litestar(
    route_handlers=[get_definition],
)
```


### Steps to reproduce

_No response_

### Screenshots

_No response_

### Logs

```bash
Traceback (most recent call last):
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/bin/litestar", line 8, in <module>
    sys.exit(run_cli())
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/__main__.py", line 6, in run_cli
    litestar_group()
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/click/core.py", line 1157, in __call__
    return self.main(*args, **kwargs)
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/rich_click/rich_command.py", line 125, in main
    with self.make_context(prog_name, args, **extra) as ctx:
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/cli/_utils.py", line 250, in make_context
    self._prepare(ctx)
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/cli/_utils.py", line 232, in _prepare
    env = ctx.obj = LitestarEnv.from_env(ctx.params.get("app_path"), ctx.params.get("app_dir"))
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/cli/_utils.py", line 127, in from_env
    loaded_app = _autodiscover_app(cwd)
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/cli/_utils.py", line 340, in _autodiscover_app
    module = importlib.import_module(import_path)
  File "/nix/store/763kk6xg6vaslzh1hgvwgdk1h582b7s3-python3-3.10.12/lib/python3.10/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1050, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1006, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 688, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 883, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/home/kasuari/dev/repo/litestar-issue-repro/app.py", line 18, in <module>
    app = Litestar(
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/app.py", line 446, in __init__
    self.register(route_handler)
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/app.py", line 620, in register
    route_handler.on_registration(self)
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/handlers/http_handlers/base.py", line 490, in on_registration
    super().on_registration(app)
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/handlers/base.py", line 527, in on_registration
    self.resolve_return_dto()
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/handlers/base.py", line 486, in resolve_return_dto
    return_dto.create_for_field_definition(
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/dto/base_dto.py", line 185, in create_for_field_definition
    backend_context[key] = backend_cls(  # type: ignore[literal-required]
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/dto/_backend.py", line 99, in __init__
    self.annotation = _maybe_wrap_in_generic_annotation(annotation, self.transfer_model_type)
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/litestar/dto/_backend.py", line 748, in _maybe_wrap_in_generic_annotation
    return safe_generic_origin_map[origin][model]  # type: ignore[index]
  File "/nix/store/763kk6xg6vaslzh1hgvwgdk1h582b7s3-python3-3.10.12/lib/python3.10/typing.py", line 312, in inner
    return func(*args, **kwds)
  File "/nix/store/763kk6xg6vaslzh1hgvwgdk1h582b7s3-python3-3.10.12/lib/python3.10/typing.py", line 1144, in __getitem__
    _check_generic(self, params, self._nparams)
  File "/home/kasuari/dev/repo/litestar-issue-repro/venv/lib/python3.10/site-packages/typing_extensions.py", line 165, in _check_generic
    raise TypeError(f"Too {'many' if alen > elen else 'few'} parameters for {cls};"
TypeError: Too few parameters for typing.Dict; actual 1, expected 2
```


### Litestar Version

2.3.2


### Platform

- [X] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- 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 Litestar 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/2737">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2737/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2737/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:

Resolving patch:
diff --git a/litestar/dto/_backend.py b/litestar/dto/_backend.py
--- a/litestar/dto/_backend.py
+++ b/litestar/dto/_backend.py
@@ -18,7 +18,6 @@
 )
 
 from msgspec import UNSET, Struct, UnsetType, convert, defstruct, field
-from typing_extensions import get_origin
 
 from litestar.dto._types import (
     CollectionType,
@@ -38,7 +37,6 @@
 from litestar.types import Empty
 from litestar.typing import FieldDefinition
 from litestar.utils import unique_name_for_scope
-from litestar.utils.typing import safe_generic_origin_map
 
 if TYPE_CHECKING:
     from litestar.connection import ASGIConnection
@@ -119,11 +117,9 @@ def __init__(
         self.override_serialization_name: bool = False
         if field_definition.is_subclass_of(DTOData):
             self.dto_data_type = field_definition.annotation
-            annotation = self.field_definition.inner_types[0].annotation
-        else:
-            annotation = field_definition.annotation
+            field_definition = self.field_definition.inner_types[0]
 
-        self.annotation = _maybe_wrap_in_generic_annotation(annotation, self.transfer_model_type)
+        self.annotation = build_annotation_for_backend(model_type, field_definition, self.transfer_model_type)
 
     def parse_model(
         self,
@@ -605,17 +601,32 @@ def _transfer_data(
     Returns:
         Data parsed into ``destination_type``.
     """
-    if field_definition.is_non_string_collection and not field_definition.is_mapping:
+    if field_definition.is_non_string_collection:
+        if not field_definition.is_mapping:
+            return field_definition.instantiable_origin(
+                _transfer_data(
+                    destination_type=destination_type,
+                    source_data=item,
+                    field_definitions=field_definitions,
+                    field_definition=field_definition.inner_types[0],
+                    is_data_field=is_data_field,
+                    override_serialization_name=override_serialization_name,
+                )
+                for item in source_data
+            )
         return field_definition.instantiable_origin(
-            _transfer_data(
-                destination_type=destination_type,
-                source_data=item,
-                field_definitions=field_definitions,
-                field_definition=field_definition.inner_types[0],
-                is_data_field=is_data_field,
-                override_serialization_name=override_serialization_name,
+            (
+                key,
+                _transfer_data(
+                    destination_type=destination_type,
+                    source_data=value,
+                    field_definitions=field_definitions,
+                    field_definition=field_definition.inner_types[1],
+                    is_data_field=is_data_field,
+                    override_serialization_name=override_serialization_name,
+                ),
             )
-            for item in source_data
+            for key, value in source_data.items()  # type: ignore[union-attr]
         )
 
     return _transfer_instance_data(
@@ -796,20 +807,30 @@ def _create_struct_for_field_definitions(
     return defstruct(model_name, struct_fields, frozen=True, kw_only=True)
 
 
-def _maybe_wrap_in_generic_annotation(annotation: Any, model: Any) -> Any:
+def build_annotation_for_backend(
+    model_type: type[Any], field_definition: FieldDefinition, transfer_model: type[Struct]
+) -> Any:
     """A helper to re-build a generic outer type with new inner type.
 
     Args:
-        annotation: The original annotation on the handler signature
-        model: The data container type
+        model_type: The original model type.
+        field_definition: The parsed type that represents the handler annotation for which the DTO is being applied.
+        transfer_model: The transfer model generated to represent the model type.
 
     Returns:
         Annotation with new inner type if applicable.
     """
-    if (origin := get_origin(annotation)) and origin in safe_generic_origin_map:
-        return safe_generic_origin_map[origin][model]  # type: ignore[index]
+    if not field_definition.inner_types:
+        if field_definition.is_subclass_of(model_type):
+            return transfer_model
+        return field_definition.annotation
+
+    inner_types = tuple(
+        build_annotation_for_backend(model_type, inner_type, transfer_model)
+        for inner_type in field_definition.inner_types
+    )
 
-    return origin[model] if (origin := get_origin(annotation)) else model
+    return field_definition.safe_generic_origin[inner_types]
 
 
 def _should_mark_private(field_definition: DTOFieldDefinition, underscore_fields_private: bool) -> bool:
diff --git a/litestar/dto/_codegen_backend.py b/litestar/dto/_codegen_backend.py
--- a/litestar/dto/_codegen_backend.py
+++ b/litestar/dto/_codegen_backend.py
@@ -349,7 +349,7 @@ def create_transfer_data(
         override_serialization_name: bool,
         field_definition: FieldDefinition | None = None,
     ) -> Callable[[Any], Any]:
-        if field_definition and field_definition.is_non_string_collection and not field_definition.is_mapping:
+        if field_definition and field_definition.is_non_string_collection:
             factory = cls(
                 is_data_field=is_data_field,
                 override_serialization_name=override_serialization_name,
@@ -390,9 +390,14 @@ def _create_transfer_data_body_nested(
             override_serialization_name=self.override_serialization_name,
         )
         transfer_func_name = self._add_to_fn_globals("transfer_data", transfer_func)
-        self._add_stmt(
-            f"{assignment_target} = {origin_name}({transfer_func_name}(item) for item in {source_data_name})"
-        )
+        if field_definition.is_mapping:
+            self._add_stmt(
+                f"{assignment_target} = {origin_name}((key, {transfer_func_name}(item)) for key, item in {source_data_name}.items())"
+            )
+        else:
+            self._add_stmt(
+                f"{assignment_target} = {origin_name}({transfer_func_name}(item) for item in {source_data_name})"
+            )
 
     def _create_transfer_instance_data(
         self,


Test patch:
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
@@ -805,3 +805,21 @@ def handler(data: Superuser) -> Superuser:
             headers={"Content-Type": "application/json; charset=utf-8"},
         )
         assert msgspec.json.decode(received.content, type=Superuser) == data
+
+
+def test_dto_returning_mapping(use_experimental_dto_backend: bool) -> None:
+    @dataclass
+    class Lexeme:
+        id: int
+        name: str
+
+    class LexemeDTO(DataclassDTO[Lexeme]):
+        config = DTOConfig(exclude={"id"}, experimental_codegen_backend=use_experimental_dto_backend)
+
+    @get(return_dto=LexemeDTO, signature_types=[Lexeme])
+    async def get_definition() -> Dict[str, Lexeme]:
+        return {"hello": Lexeme(id=1, name="hello"), "world": Lexeme(id=2, name="world")}
+
+    with create_test_client(route_handlers=[get_definition]) as client:
+        response = client.get("/")
+        assert response.json() == {"hello": {"name": "hello"}, "world": {"name": "world"}}
diff --git a/tests/unit/test_openapi/conftest.py b/tests/unit/test_openapi/conftest.py
--- a/tests/unit/test_openapi/conftest.py
+++ b/tests/unit/test_openapi/conftest.py
@@ -61,7 +61,7 @@ def create_person(
 
         @post(path="/bulk", dto=PartialDataclassPersonDTO)
         def bulk_create_person(
-            self, data: List[DTOData[DataclassPerson]], secret_header: str = Parameter(header="secret")
+            self, data: DTOData[List[DataclassPerson]], secret_header: str = Parameter(header="secret")
         ) -> List[DataclassPerson]:
             return []
 
@@ -73,7 +73,7 @@ def bulk_update_person(
 
         @patch(path="/bulk", dto=PartialDataclassPersonDTO)
         def bulk_partial_update_person(
-            self, data: List[DTOData[DataclassPerson]], secret_header: str = Parameter(header="secret")
+            self, data: DTOData[List[DataclassPerson]], secret_header: str = Parameter(header="secret")
         ) -> List[DataclassPerson]:
             return []