unihttp is a modern library for creating declarative API clients.
- Features
- Installation
- Quick Start
- Markers Reference
- Middleware
- Error Handling
- Custom JSON Serialization
- Powered by Adaptix
- Declarative: Define API methods using standard Python type hints.
- Type-Safe: Full support for static type checking.
- Backend Agnostic: Works with
httpx,aiohttp,requestsandniquests. - Extensible: Powerful middleware and error handling systems.
pip install unihttpTo include a specific HTTP backend (recommended):
pip install "unihttp[httpx]" # For HTTPX (Sync/Async) support
# OR
pip install "unihttp[niquests]" # For niquests (Sync/Async) support
# OR
pip install "unihttp[requests]" # For Requests (Sync) support
# OR
pip install "unihttp[aiohttp]" # For Aiohttp (Async) supportunihttp uses markers to map method arguments to HTTP request components.
from dataclasses import dataclass
from unihttp import BaseMethod, Path, Query, Body, Header, Form, File
@dataclass
class User:
id: int
name: str
email: str
@dataclass
class GetUser(BaseMethod[User]):
__url__ = "/users/{id}"
__method__ = "GET"
id: Path[int]
compact: Query[bool] = False
@dataclass
class CreateUser(BaseMethod[User]):
__url__ = "/users"
__method__ = "POST"
name: Body[str]
email: Body[str]You can choose between a purely declarative style using bind_method or a more imperative style using call_method.
This is the most concise way to define your client. You simply bind the methods to the client class.
Note
PyCharm Users: There is currently a known issue with displaying type hints for descriptors like bind_method (see PY-51768). This is expected to be fixed in the 2026.1 version.
from unihttp import bind_method
from unihttp.clients.httpx import HTTPXSyncClient
from unihttp.serializers.adaptix import DEFAULT_RETORT
class UserClient(HTTPXSyncClient):
get_user = bind_method(GetUser)
create_user = bind_method(CreateUser)
client = UserClient(
base_url="https://api.example.com",
request_dumper=DEFAULT_RETORT,
response_loader=DEFAULT_RETORT
)
user = client.get_user(id=123)If you need more control, need to preprocess arguments, or simply prefer explicit method definitions, you can define methods in the client and use call_method.
class UserClient(HTTPXSyncClient):
def get_user(self, user_id: int) -> User:
# You can add custom logic here before the call
return self.call_method(GetUser(id=user_id))
def create_user(self, name: str, email: str) -> User:
return self.call_method(CreateUser(name=name, email=email))unihttp provides several markers to define how arguments are serialized:
Path: Substitutes placeholders in the__url__(e.g.,/users/{id}).Query: Adds parameters to the URL query string.Body: Sends data as the JSON request body.Header: Adds HTTP headers to the request.Form: Sends data as form-encoded (application/x-www-form-urlencoded).File: Used for multipart file uploads.UploadFile: A wrapper for file uploads that allows specifying a filename and content type (e.g.,UploadFile(b"content", filename="test.txt")).
Middleware allows you to intercept requests and responses globally. This is useful for logging, authentication, or modifying requests on the fly.
from unihttp.middlewares.base import Middleware
from unihttp.http.request import HTTPRequest
from unihttp.http.response import HTTPResponse
class LoggingMiddleware(Middleware):
def handle(self, request: HTTPRequest, next_handler) -> HTTPResponse:
print(f"Requesting {request.url}")
# Call the next handler in the chain
response = next_handler(request)
print(f"Status: {response.status_code}")
return response
client = HTTPXSyncClient(
# ...
middleware=[LoggingMiddleware()]
)unihttp offers a layered approach to error handling, giving you control at multiple levels.
Override on_error in your Method class to handle specific status codes for that endpoint.
@dataclass
class GetUser(BaseMethod[User]):
# ...
def on_error(self, response):
if response.status_code == 404:
return None # Return None (or a default object) instead of raising
return super().on_error(response)Override handle_error in your Client class to catch errors that weren't handled by the method. This is great for global concerns like token expiration.
class MyClient(HTTPXSyncClient):
def handle_error(self, response: HTTPResponse, method):
if response.status_code == 401:
raise MyAuthException("Session expired, please log in again.")You can wrap the execution in a try/except block or inspect the response within a middleware. This is useful for logging exceptions or global error reporting.
class ErrorReportingMiddleware(Middleware):
def handle(self, request: HTTPRequest, next_handler):
try:
return next_handler(request)
except Exception as e:
# Report exception to external service
sentry_sdk.capture_exception(e)
raiseSometimes APIs return 200 OK but the body contains an error message. You can override validate_response to handle this.
# In your Method or Client
def validate_response(self, response: HTTPResponse):
if "error" in response.data:
raise ApiError(response.data["error"])You can use high-performance JSON libraries like orjson or ujson by passing custom json_dumps and json_loads to the client.
import orjson
from unihttp.clients.httpx import HTTPXSyncClient
client = HTTPXSyncClient(
# ...
json_dumps=lambda x: orjson.dumps(x).decode(),
json_loads=orjson.loads
)unihttp leverages adaptix for all data serialization and validation tasks. adaptix is a powerful and extremely fast library that allows you to:
- Validate data strictly against your type hints.
- Serialize/Deserialize complex data structures (dataclasses, TypedDicts, etc.) with high performance.
- Customize serialization logic (field renaming, value transformation) using
Retort.
Crucially, you can customize serialization down to individual fields in each method, giving you granular control over how your data is processed.
from adaptix import Retort, name_mapping, P
from unihttp.serializers.adaptix import AdaptixDumper, AdaptixLoader, DEFAULT_RETORT
# Create a Retort that renames specific fields (e.g., camelCase for external API)
retort = Retort(
recipe=[
name_mapping(map={"user_name": "userName"}),
dumper(P[CreateUser].email, lambda x: x.lower()),
]
)
retort.extend(DEFAULT_RETORT)
client = UserClient(
# ...
request_dumper=AdaptixDumper(retort),
response_loader=AdaptixLoader(retort),
)