diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index dfe87acab8..751658f4d7 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -269,7 +269,7 @@ to test them alongside the rest of the test suite, ensuring they do not become s Please follow the next guidelines when adding a new example: - Add the example in the corresponding module directory in ``/docs/examples`` or create a new one if necessary -- Create a suite for the module in ``/docs/examples/tests`` that tests the aspects of the example that it demonstrates +- Create a suite for the module in ``/tests/examples`` that tests the aspects of the example that it demonstrates - Reference the example in the rst file with an external reference code block, e.g. .. code-block:: rst diff --git a/docs/examples/encoding_decoding/__init__.py b/docs/examples/encoding_decoding/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/examples/encoding_decoding/custom_type_encoding_decoding.py b/docs/examples/encoding_decoding/custom_type_encoding_decoding.py new file mode 100644 index 0000000000..944482968d --- /dev/null +++ b/docs/examples/encoding_decoding/custom_type_encoding_decoding.py @@ -0,0 +1,77 @@ +from typing import Any, Type + +from msgspec import Struct + +from litestar import Litestar, post + + +class TenantUser: + """Custom Type that represents a user associated to a tenant + + Parsed from / serialized to a combined tenant + user id string of the form + + TENANTPREFIX_USERID + + i.e. separated by underscore. + """ + + tenant_prefix: str + user_id: str + + def __init__(self, tenant_prefix: str, user_id: str) -> None: + self.tenant_prefix = tenant_prefix + self.user_id = user_id + + @classmethod + def from_string(cls, s: str) -> "TenantUser": + splits = s.split("_", maxsplit=1) + if len(splits) < 2: + raise ValueError( + "Could not split up tenant user id string. " + "Expecting underscore for separation of tenant prefix and user id." + ) + return cls(tenant_prefix=splits[0], user_id=splits[1]) + + def to_combined_str(self) -> str: + return self.tenant_prefix + "_" + self.user_id + + +def tenant_user_type_predicate(type: Type) -> bool: + return type is TenantUser + + +def tenant_user_enc_hook(u: TenantUser) -> Any: + return u.to_combined_str() + + +def tenant_user_dec_hook(tenant_user_id_str: str) -> TenantUser: + return TenantUser.from_string(tenant_user_id_str) + + +def general_dec_hook(type: Type, obj: Any) -> Any: + if tenant_user_type_predicate(type): + return tenant_user_dec_hook(obj) + + raise NotImplementedError(f"Encountered unknown type during decoding: {type!s}") + + +class UserAsset(Struct): + user: TenantUser + name: str + + +@post("/asset", sync_to_thread=False) +def create_asset( + data: UserAsset, +) -> UserAsset: + assert isinstance(data.user, TenantUser) + return data + + +app = Litestar( + [create_asset], + type_encoders={TenantUser: tenant_user_enc_hook}, # tell litestar how to encode TenantUser + type_decoders=[(tenant_user_type_predicate, general_dec_hook)], # tell litestar how to decode TenantUser +) + +# run: /asset -X POST -H "Content-Type: application/json" -d '{"name":"SomeAsset","user":"TenantA_Somebody"}' diff --git a/docs/examples/encoding_decoding/custom_type_pydantic.py b/docs/examples/encoding_decoding/custom_type_pydantic.py new file mode 100644 index 0000000000..261cc079e4 --- /dev/null +++ b/docs/examples/encoding_decoding/custom_type_pydantic.py @@ -0,0 +1,65 @@ +from pydantic import BaseModel, BeforeValidator, ConfigDict, PlainSerializer, WithJsonSchema +from typing_extensions import Annotated + +from litestar import Litestar, post + + +class TenantUser: + """Custom Type that represents a user associated to a tenant + + Parsed from / serialized to a combined tenant + user id string of the form + + TENANTPREFIX_USERID + + i.e. separated by underscore. + """ + + tenant_prefix: str + user_id: str + + def __init__(self, tenant_prefix: str, user_id: str) -> None: + self.tenant_prefix = tenant_prefix + self.user_id = user_id + + @classmethod + def from_string(cls, s: str) -> "TenantUser": + splits = s.split("_", maxsplit=1) + if len(splits) < 2: + raise ValueError( + "Could not split up tenant user id string. " + "Expecting underscore for separation of tenant prefix and user id." + ) + return cls(tenant_prefix=splits[0], user_id=splits[1]) + + def to_combined_str(self) -> str: + return self.tenant_prefix + "_" + self.user_id + + +PydAnnotatedTenantUser = Annotated[ + TenantUser, + BeforeValidator(lambda x: TenantUser.from_string(x)), + PlainSerializer(lambda x: x.to_combined_str(), return_type=str), + WithJsonSchema({"type": "string"}, mode="serialization"), +] + + +class UserAsset(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + user: PydAnnotatedTenantUser + name: str + + +@post("/asset", sync_to_thread=False) +def create_asset( + data: UserAsset, +) -> UserAsset: + assert isinstance(data.user, TenantUser) + return data + + +app = Litestar( + [create_asset], +) + +# run: /asset -X POST -H "Content-Type: application/json" -d '{"name":"SomeAsset","user":"TenantA_Somebody"}' diff --git a/docs/usage/applications.rst b/docs/usage/applications.rst index c0ff1e367c..293d2be120 100644 --- a/docs/usage/applications.rst +++ b/docs/usage/applications.rst @@ -258,6 +258,6 @@ Parameters that support layering are: * :doc:`return_dto ` * ``security`` * ``tags`` -* ``type_decoders`` -* ``type_encoders`` +* :doc:`type_decoders ` +* :doc:`type_encoders ` * :ref:`websocket_class ` diff --git a/docs/usage/custom-types.rst b/docs/usage/custom-types.rst new file mode 100644 index 0000000000..4dad94e86a --- /dev/null +++ b/docs/usage/custom-types.rst @@ -0,0 +1,34 @@ +Custom types +============ + +Data serialization / deserialization (encoding / decoding) and validation are important parts of any API framework. + +In addition to being capable to encode / decode and validate many standard types, litestar supports Python's builtin dataclasses and libraries like Pydantic and msgspec. + +However, sometimes you may need to employ a custom type. + +Using type encoders / decoders +------------------------------ + +Litestar supports a mechanism where you provide encoding and decoding hook functions which translate your type in / to a type that it knows. You can provide them via the ``type_encoders`` and ``type_decoders`` :term:`parameters ` which can be defined on every layer. For example see the :doc:`litestar app reference `. + +.. admonition:: Layered architecture + + ``type_encoders`` and ``type_decoders`` are part of Litestar's layered architecture, which means you can set them on every layer of the application. If you set them on multiple layers, + the layer closest to the route handler will take precedence. + + You can read more about this here: + :ref:`Layered architecture ` + +Here is an example: + +.. literalinclude:: /examples/encoding_decoding/custom_type_encoding_decoding.py + :caption: Tell Litestar how to encode and decode a custom type + +Custom Pydantic types +--------------------- + +If you use a custom Pydantic type you can use it directly: + +.. literalinclude:: /examples/encoding_decoding/custom_type_pydantic.py + :caption: Tell Litestar how to encode and decode a custom Pydantic type diff --git a/docs/usage/index.rst b/docs/usage/index.rst index eb6d846682..f48b397fa7 100644 --- a/docs/usage/index.rst +++ b/docs/usage/index.rst @@ -26,6 +26,7 @@ Usage responses security/index static-files + custom-types stores templating testing diff --git a/tests/examples/test_encoding_decoding/__init__.py b/tests/examples/test_encoding_decoding/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/examples/test_encoding_decoding/test_custom_type_encoding_decoding.py b/tests/examples/test_encoding_decoding/test_custom_type_encoding_decoding.py new file mode 100644 index 0000000000..166edfed3a --- /dev/null +++ b/tests/examples/test_encoding_decoding/test_custom_type_encoding_decoding.py @@ -0,0 +1,17 @@ +from docs.examples.encoding_decoding.custom_type_encoding_decoding import app + +from litestar.status_codes import HTTP_201_CREATED +from litestar.testing import TestClient + + +def test_custom_type_encoding_decoding_works() -> None: + with TestClient(app) as client: + response = client.post( + "/asset", + json={ + "user": "TenantA_Somebody", + "name": "Some Asset", + }, + ) + + assert response.status_code == HTTP_201_CREATED diff --git a/tests/examples/test_encoding_decoding/test_custom_type_pydantic.py b/tests/examples/test_encoding_decoding/test_custom_type_pydantic.py new file mode 100644 index 0000000000..a17e864cce --- /dev/null +++ b/tests/examples/test_encoding_decoding/test_custom_type_pydantic.py @@ -0,0 +1,17 @@ +from docs.examples.encoding_decoding.custom_type_pydantic import app + +from litestar.status_codes import HTTP_201_CREATED +from litestar.testing import TestClient + + +def test_custom_type_encoding_decoding_works() -> None: + with TestClient(app) as client: + response = client.post( + "/asset", + json={ + "user": "TenantA_Somebody", + "name": "Some Asset", + }, + ) + + assert response.status_code == HTTP_201_CREATED