diff --git a/README.md b/README.md index ea5ccf2..e1dac75 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ - [Custom JSON Serialization](#custom-json-serialization) - [Powered by Adaptix](#powered-by-adaptix) - [Pydantic Integration](#pydantic-integration) +- [msgspec Integration](#msgspec-integration) ## Features @@ -62,10 +63,11 @@ pip install "unihttp[zapros]" # For Zapros (Sync/Async) support 1. **Adaptix** (recommended): High-performance serialization for standard Python types (dataclasses, TypedDict). 2. **Pydantic**: Native support for Pydantic models. +3. **msgspec**: Native support for `msgspec.Struct` models — ideal if you already define your models as structs and want them serialized directly. You will need to pass the appropriate `request_dumper` and `response_loader` when initializing your client. -See [Powered by Adaptix](#powered-by-adaptix) or [Pydantic Integration](#pydantic-integration) for configuration -details. +See [Powered by Adaptix](#powered-by-adaptix), [Pydantic Integration](#pydantic-integration) or +[msgspec Integration](#msgspec-integration) for configuration details. ## Quick Start @@ -350,4 +352,53 @@ client = RequestsSyncClient( # Now standard Pydantic models are serialized/validated automatically client.call_method(CreateUser(user=User(id=1, name="Alice"))) +``` + +## msgspec Integration + +If your models are already defined as [`msgspec`](https://github.com/jcrist/msgspec) structs, `unihttp` can serialize and validate them directly — no need to duplicate them as Pydantic or adaptix models. + +First, install the optional dependency: + +```bash +pip install "unihttp[msgspec]" +``` + +Then, configure your client to use the msgspec serializers: + +```python +from dataclasses import dataclass + +import msgspec +from unihttp.clients.requests import RequestsSyncClient +from unihttp.markers import Body +from unihttp.method import BaseMethod +from unihttp.serializers.msgspec import MsgspecDumper, MsgspecLoader + + +class User(msgspec.Struct): + id: int + name: str + + +@dataclass +class CreateUser(BaseMethod[User]): + __url__ = "/users" + __method__ = "POST" + + user: Body[User] + + +# Initialize serializers +dumper = MsgspecDumper() +loader = MsgspecLoader() + +client = RequestsSyncClient( + base_url="https://api.example.org", + request_dumper=dumper, + response_loader=loader +) + +# Now msgspec structs are serialized/validated automatically +client.call_method(CreateUser(user=User(id=1, name="Alice"))) ``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ddefab8..f71b519 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ niquests = ["niquests>=3.17.0"] zapros = ["zapros>=0.11.0"] pydantic = ["pydantic>=2.0.0"] adaptix = ["adaptix>=3.0.0b12"] +msgspec = ["msgspec>=0.18.0"] [dependency-groups] @@ -57,7 +58,8 @@ optionals = [ "unihttp[niquests]", "unihttp[zapros]", "unihttp[pydantic]", - "unihttp[adaptix]" + "unihttp[adaptix]", + "unihttp[msgspec]" ] lint = [ diff --git a/src/unihttp/serializers/msgspec/__init__.py b/src/unihttp/serializers/msgspec/__init__.py new file mode 100644 index 0000000..9436d7d --- /dev/null +++ b/src/unihttp/serializers/msgspec/__init__.py @@ -0,0 +1,6 @@ +from .serialize import MsgspecDumper, MsgspecLoader + +__all__ = [ + "MsgspecDumper", + "MsgspecLoader", +] diff --git a/src/unihttp/serializers/msgspec/serialize.py b/src/unihttp/serializers/msgspec/serialize.py new file mode 100644 index 0000000..cbc2ac3 --- /dev/null +++ b/src/unihttp/serializers/msgspec/serialize.py @@ -0,0 +1,73 @@ +from typing import Any, TypeVar, get_args, get_origin, get_type_hints + +from unihttp.http import UploadFile +from unihttp.markers import Marker +from unihttp.omitted import Omitted +from unihttp.serialize import RequestDumper, ResponseLoader + +import msgspec + +T = TypeVar("T") + + +class MsgspecDumper(RequestDumper): + def dump(self, obj: Any) -> Any: + data: dict[str, Any] = { + "path": {}, + "query": {}, + "header": {}, + "body": {}, + "file": {}, + "form": {}, + } + + cls = type(obj) + + try: + type_hints = get_type_hints(cls, include_extras=True) + except Exception: + type_hints = cls.__annotations__ # Fallback + + for field_name, field_value in vars(obj).items(): + if field_name.startswith("__"): + continue + + hint = type_hints.get(field_name) + if hint is None: + continue + + self._process_field(data, field_name, field_value, hint) + + return data + + def _process_field( + self, + data: dict[str, Any], + field_name: str, + field_value: Any, + hint: Any, + ) -> None: + if isinstance(field_value, Omitted): + return + + marker = None + if get_origin(hint) is not None: + for arg in get_args(hint): + if isinstance(arg, Marker): + marker = arg + break + + if marker: + if isinstance(field_value, UploadFile): + serialized_value = field_value.to_tuple() + else: + serialized_value = msgspec.to_builtins(field_value) + + target_dict = data.get(marker.name) + if target_dict is not None and isinstance(target_dict, dict): + target_dict[field_name] = serialized_value + + +class MsgspecLoader(ResponseLoader): + def load(self, data: Any, tp: type[T]) -> T: + return msgspec.convert(data, type=tp) diff --git a/tests/test_features/test_msgspec_serializer.py b/tests/test_features/test_msgspec_serializer.py new file mode 100644 index 0000000..75807f9 --- /dev/null +++ b/tests/test_features/test_msgspec_serializer.py @@ -0,0 +1,226 @@ +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from uuid import UUID + +import msgspec +import pytest + +from unihttp.http import UploadFile +from unihttp.markers import Body, File, Form, Header, Path, Query +from unihttp.method import BaseMethod +from unihttp.serializers.msgspec import MsgspecDumper, MsgspecLoader + + +class User(msgspec.Struct): + id: int + name: str + + +def test_msgspec_loader(): + loader = MsgspecLoader() + data = {"id": 1, "name": "Alice"} + + user = loader.load(data, User) + assert isinstance(user, User) + assert user.id == 1 + assert user.name == "Alice" + + +def test_msgspec_loader_list(): + loader = MsgspecLoader() + data = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] + + users = loader.load(data, list[User]) + assert len(users) == 2 + assert users[0].name == "Alice" + assert users[1].name == "Bob" + + +def test_msgspec_loader_validation_error(): + loader = MsgspecLoader() + data = {"id": "not-an-int", "name": "Alice"} + + with pytest.raises(msgspec.ValidationError): + loader.load(data, User) + + +@dataclass +class CreateUser(BaseMethod[User]): + __url__ = "/users" + __method__ = "POST" + + token: Header[str] + user_id: Path[int] + user: Body[User] + q: Query[str] = "default" + + +class Status(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + +@dataclass +class ComplexParams(BaseMethod[None]): + __url__ = "/complex" + __method__ = "GET" + + status: Query[Status] + since: Query[datetime] + tracking_id: Path[UUID] + + +@dataclass +class NestedBody(BaseMethod[None]): + __url__ = "/bulk" + __method__ = "POST" + + users: Body[list[User]] + + +@dataclass +class OptionalParams(BaseMethod[None]): + __url__ = "/optional" + __method__ = "GET" + + q: Query[str | None] = None + limit: Query[int | None] = None + + +def test_msgspec_dumper_simple(): + dumper = MsgspecDumper() + method = CreateUser( + token="abc", user_id=123, user=User(id=1, name="John"), q="search" + ) + + result = dumper.dump(method) + + assert result["header"]["token"] == "abc" + assert result["path"]["user_id"] == 123 + assert result["query"]["q"] == "search" + assert result["body"]["user"] == {"id": 1, "name": "John"} + + +def test_msgspec_complex_types_serialization(): + dumper = MsgspecDumper() + dt = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + uid = UUID("12345678-1234-5678-1234-567812345678") + + method = ComplexParams(status=Status.ACTIVE, since=dt, tracking_id=uid) + + result = dumper.dump(method) + + assert result["query"]["status"] == "active" + assert result["query"]["since"] == "2023-01-01T12:00:00Z" + assert result["path"]["tracking_id"] == "12345678-1234-5678-1234-567812345678" + + +def test_msgspec_nested_body_list(): + dumper = MsgspecDumper() + users = [User(id=1, name="A"), User(id=2, name="B")] + method = NestedBody(users=users) + + result = dumper.dump(method) + + assert result["body"]["users"] == [ + {"id": 1, "name": "A"}, + {"id": 2, "name": "B"}, + ] + + +def test_msgspec_optional_fields(): + dumper = MsgspecDumper() + + method = OptionalParams(q="search", limit=10) + result = dumper.dump(method) + assert result["query"]["q"] == "search" + assert result["query"]["limit"] == 10 + + method = OptionalParams() + result = dumper.dump(method) + assert result["query"]["q"] is None + assert result["query"]["limit"] is None + + +@dataclass +class FileUpload(BaseMethod[None]): + __url__ = "/upload" + __method__ = "POST" + + file: File[UploadFile] + description: Form[str] + + +def test_msgspec_dumper_upload(): + dumper = MsgspecDumper() + uf = UploadFile(file=b"test", filename="test.txt", content_type="text/plain") + method = FileUpload(file=uf, description="desc") + + result = dumper.dump(method) + + # UploadFile.to_tuple() returns (filename, content, content_type) + assert result["file"]["file"] == ("test.txt", b"test", "text/plain") + assert result["form"]["description"] == "desc" + + +def test_msgspec_dumper_no_markers_ignored(): + @dataclass + class NoMarkerMethod(BaseMethod[None]): + __url__ = "/" + __method__ = "GET" + internal: str # No marker + + method = NoMarkerMethod(internal="secret") + result = MsgspecDumper().dump(method) + + assert "internal" not in result.get("body", {}) + assert "internal" not in result.get("query", {}) + + +def test_msgspec_dumper_fallback_on_exception(): + """Dumper falls back to __annotations__ if get_type_hints raises.""" + from unittest.mock import patch + + dumper = MsgspecDumper() + method = CreateUser(token="abc", user_id=1, user=User(id=1, name="a")) + + with patch( + "unihttp.serializers.msgspec.serialize.get_type_hints", + side_effect=ValueError("Boom"), + ): + result = dumper.dump(method) + + assert result["header"]["token"] == "abc" + + +def test_msgspec_dumper_skips_dunder_and_untyped(): + dumper = MsgspecDumper() + method = CreateUser(token="abc", user_id=1, user=User(id=1, name="a")) + + method.__dict__["__internal_state__"] = "ignore me" + method.__dict__["dynamic_attr"] = "ignore me too" + + result = dumper.dump(method) + + assert "__internal_state__" not in result["body"] + assert "dynamic_attr" not in result["body"] + assert result["header"]["token"] == "abc" + + +@dataclass +class BytesBody(BaseMethod[None]): + __url__ = "/bytes" + __method__ = "POST" + + payload: Body[bytes] + + +def test_msgspec_dumper_bytes_base64(): + dumper = MsgspecDumper() + method = BytesBody(payload=b"hello") + + result = dumper.dump(method) + + # msgspec.to_builtins encodes bytes as base64 (backend-specific behavior) + assert result["body"]["payload"] == "aGVsbG8=" diff --git a/tests/test_features/test_omitted_agnostic.py b/tests/test_features/test_omitted_agnostic.py index ed7bb48..0d06730 100644 --- a/tests/test_features/test_omitted_agnostic.py +++ b/tests/test_features/test_omitted_agnostic.py @@ -5,6 +5,7 @@ from unihttp.markers import Body from unihttp.omitted import Omitted from unihttp.serializers.adaptix import DEFAULT_RETORT +from unihttp.serializers.msgspec import MsgspecDumper from unihttp.serializers.pydantic import PydanticDumper @dataclass @@ -50,3 +51,17 @@ def test_adaptix_omitted_handling(): method = OmittedMethod(optional_field="present") dumped = retort.dump(method) assert dumped["body"]["optional_field"] == "present" + +def test_msgspec_omitted_handling(): + dumper = MsgspecDumper() + + # Case 1: Default (Omitted) -> key should be absent + method = OmittedMethod() + dumped = dumper.dump(method) + assert "optional_field" not in dumped["body"] + assert dumped["body"]["mandatory_field"] == "mandatory" + + # Case 2: Provided value -> key should be present + method = OmittedMethod(optional_field="present") + dumped = dumper.dump(method) + assert dumped["body"]["optional_field"] == "present"