Skip to content

Commit

Permalink
feat(DTO): introduce forbid_unknown_fields config (#3690)
Browse files Browse the repository at this point in the history
feat(DTO): introduce forbid_unknown_fields config
  • Loading branch information
provinzkraut authored Aug 24, 2024
1 parent b3cf2fd commit 23ba256
Show file tree
Hide file tree
Showing 6 changed files with 90 additions and 3 deletions.
27 changes: 27 additions & 0 deletions docs/examples/data_transfer_objects/factory/unknown_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

from dataclasses import dataclass

from typing_extensions import Annotated

from litestar import Litestar, post
from litestar.dto import DataclassDTO, DTOConfig


@dataclass
class User:
id: str


UserDTO = DataclassDTO[Annotated[User, DTOConfig(forbid_unknown_fields=True)]]


@post("/users", dto=UserDTO)
async def create_user(data: User) -> User:
return data


app = Litestar([create_user])


# run: /users -H "Content-Type: application/json" -d '{"id": "1", "name": "Peter"}'
14 changes: 14 additions & 0 deletions docs/usage/dto/1-abstract-dto.rst
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,20 @@ We then add a ``B`` instance to the data (line 39), which includes a reference b
return data can see that ``b`` is included in the response data, however ``b.a`` is not, due to the default
``max_nested_depth`` of ``1``.

Handling unknown fields
-----------------------

By default, DTOs will silently ignore unknown fields in the source data. This behaviour
can be configured using the ``forbid_unknown_fields`` parameter of the
:class:`DTOConfig <litestar.dto.config.DTOConfig>`. When set to ``True`` a validation
error response will be returned if the data contains a field not defined on the model:

.. literalinclude:: /examples/data_transfer_objects/factory/unknown_fields.py
:caption: Type checking
:language: python
:linenos:


DTO Data
--------

Expand Down
19 changes: 16 additions & 3 deletions litestar/dto/_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ def __init__(
handler_id: The name of the handler that this backend is for.
is_data_field: Whether the field is a subclass of DTOData.
model_type: Model type.
wrapper_attribute_name: If the data that DTO should operate upon is wrapped in a generic datastructure, this is the name of the attribute that the data is stored in.
wrapper_attribute_name: If the data that DTO should operate upon is wrapped in a generic datastructure,
this is the name of the attribute that the data is stored in.
"""
self.dto_factory: Final[type[AbstractDTO]] = dto_factory
self.field_definition: Final[FieldDefinition] = field_definition
Expand Down Expand Up @@ -209,7 +210,10 @@ def create_transfer_model_type(
struct_name = self._create_transfer_model_name(model_name)

struct = _create_struct_for_field_definitions(
struct_name, field_definitions, self.dto_factory.config.rename_strategy
model_name=struct_name,
field_definitions=field_definitions,
rename_strategy=self.dto_factory.config.rename_strategy,
forbid_unknown_fields=self.dto_factory.config.forbid_unknown_fields,
)
setattr(struct, "__schema_name__", struct_name)
return struct
Expand Down Expand Up @@ -785,9 +789,11 @@ def _create_struct_field_meta_for_field_definition(field_definition: TransferDTO


def _create_struct_for_field_definitions(
*,
model_name: str,
field_definitions: tuple[TransferDTOFieldDefinition, ...],
rename_strategy: RenameStrategy | dict[str, str] | None,
forbid_unknown_fields: bool,
) -> type[Struct]:
struct_fields: list[tuple[str, type] | tuple[str, type, type]] = []

Expand All @@ -809,7 +815,14 @@ def _create_struct_for_field_definitions(
_create_msgspec_field(field_definition),
)
)
return defstruct(model_name, struct_fields, frozen=True, kw_only=True, rename=rename_strategy)
return defstruct(
model_name,
struct_fields,
frozen=True,
kw_only=True,
rename=rename_strategy,
forbid_unknown_fields=forbid_unknown_fields,
)


def build_annotation_for_backend(
Expand Down
2 changes: 2 additions & 0 deletions litestar/dto/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ class DTOConfig:
"""Fields starting with an underscore are considered private and excluded from data transfer."""
experimental_codegen_backend: bool | None = None
"""Use the experimental codegen backend"""
forbid_unknown_fields: bool = False
"""Raise an exception for fields present in the raw data that are not defined on the model"""

def __post_init__(self) -> None:
if self.include and self.exclude:
Expand Down
8 changes: 8 additions & 0 deletions tests/examples/test_dto/test_example_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,11 @@ def test_response_return_data_app() -> None:
assert response.status_code == 200
assert response.json() == {"id": 1, "name": "Litestar User"}
assert response.headers["X-Total-Count"] == "1"


def test_unknown_fields() -> None:
from docs.examples.data_transfer_objects.factory.unknown_fields import app

with TestClient(app) as client:
response = client.post("/users", json={"id": "1", "name": "Peter"})
assert response.status_code == 400
23 changes: 23 additions & 0 deletions tests/unit/test_dto/test_factory/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1058,3 +1058,26 @@ async def create_item_with_dto(data: Annotated[Item, body]) -> Item:
item_schema = openapi_schema.components.schemas["Item"]
item_with_dto_schema = openapi_schema.components.schemas["CreateItemWithDtoItemRequestBody"]
assert item_schema.examples == item_with_dto_schema.examples


@pytest.mark.parametrize("forbid_unknown_fields, expected_status_code", [(False, 201), (True, 400)])
def test_forbid_unknown_fields(
use_experimental_dto_backend: bool, forbid_unknown_fields: bool, expected_status_code: int
) -> None:
@dataclass
class Foo:
bar: str

config = DTOConfig(
forbid_unknown_fields=forbid_unknown_fields,
experimental_codegen_backend=use_experimental_dto_backend,
)
dto = DataclassDTO[Annotated[Foo, config]]

@post(dto=dto, signature_types=[Foo])
def handler(data: Foo) -> Foo:
return data

with create_test_client(route_handlers=[handler]) as client:
response = client.post("/", json={"bar": "hello", "baz": "given"})
assert response.status_code == expected_status_code

0 comments on commit 23ba256

Please sign in to comment.