Skip to content

Requests & Responses

Request styles

Each endpoint type fixes a request style that decides where non-path fields go:

Request style Endpoint types Destination
RequestStyle.QUERY Get, Delete URL query string
RequestStyle.BODY Post, Put, Patch JSON request body

Requests are dumped with exclude_none=True, so optional fields left as None are omitted from the query string / body entirely.

Serialization mode

Request models are serialized with Pydantic's model_dump. The mode is configurable per client via the model_dump_mode argument:

model_dump_mode Behaviour
"json" (default) Coerces values to JSON-compatible types (e.g. datetime → ISO string).
"python" Keeps Python-native values (the Pydantic default).
client = EventAPI(
    base_url="https://api.example.com",
    backend=backend,
    model_dump_mode="python",  # opt out of JSON coercion
)

The default "json" mode means request models that contain types which are not natively JSON-serializable — such as datetime, Decimal, or UUID — are coerced to their JSON representation before being sent. This matters most for JSON bodies: in "python" mode those values are left as-is and encoding the body raises a TypeError. Opt into "python" only if a custom backend does its own serialization and needs the native values.

Response types

The response model (the second type parameter) controls how the HTTP response is turned into a Python value:

Response model Response style Call returns
A Pydantic model ResponseStyle.JSON An instance of that model
TextResponse ResponseStyle.TEXT TextResponse(content=<str>)
BytesResponse ResponseStyle.BYTES BytesResponse(content=<bytes>)
None ResponseStyle.NONE None
from typing import Literal
from clientcraft import Get, Delete, TextResponse, BytesResponse
from clientcraft.client import APIClient


class FilesAPI(APIClient):
    # JSON (default) — body parsed into the Pydantic model
    get_user: Get[GetUserRequest, User, Literal["/users/{user_id}"]]

    # Plain text
    health_check: Get[None, TextResponse, Literal["/health"]]

    # Raw bytes
    download: Get[GetFileRequest, BytesResponse, Literal["/files/{file_id}"]]

    # No body expected (e.g. 204) — returns None
    delete_user: Delete[DeleteUserRequest, None, Literal["/users/{user_id}"]]

Text and bytes wrappers

TextResponse and BytesResponse are tiny Pydantic models with a single content field. They exist so every response — JSON or not — flows through the same typed machinery.

result = client.health_check()
assert isinstance(result, TextResponse)
print(result.content)  # the response body as str

Empty JSON bodies

For a JSON response model, an empty response body is handled gracefully: the model is validated against {}. If the model has required fields this raises a Pydantic ValidationError (which is the correct signal that the server returned nothing usable).

Headers

Content-Type: application/json and Accept: application/json are sent by default, merged with any default_headers you pass to the client. On success the parsed value is returned; response headers are currently surfaced only on errors (see Error Handling).