Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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")))
```
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -57,7 +58,8 @@ optionals = [
"unihttp[niquests]",
"unihttp[zapros]",
"unihttp[pydantic]",
"unihttp[adaptix]"
"unihttp[adaptix]",
"unihttp[msgspec]"
]

lint = [
Expand Down
6 changes: 6 additions & 0 deletions src/unihttp/serializers/msgspec/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .serialize import MsgspecDumper, MsgspecLoader

__all__ = [
"MsgspecDumper",
"MsgspecLoader",
]
73 changes: 73 additions & 0 deletions src/unihttp/serializers/msgspec/serialize.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading