Typed API clients: operations¶
An Operation describes one endpoint.
The constant parts (HTTP method, path template) are class attributes; the
variable parts are typed dataclass fields, placed into the request by the
specifiers from action0.client.fields; the generic parameter is
the parsed result type:
from dataclasses import dataclass
from typing import Any
from action0.client import JsonOperation
from action0.client import path_param
from action0.client import query
from action0.req import Method
@dataclass
class Item:
id: int
name: str
class GetItem(JsonOperation[Item]):
method = Method.GET
path = "/items/{item_id}"
item_id: int = path_param() # fills the {item_id} placeholder
expand: bool | None = query(default=None) # ?expand=...; None = not sent
def load_json(self, data: Any) -> Item:
return Item(id=data["id"], name=data["name"])
Subclasses become keyword-only dataclasses automatically (no decorator
needed), so GetItem(item_id=42) is type-checked, has a useful repr()
and compares by value. The class definition is validated eagerly: a path
placeholder without a matching path_param() field (or vice versa), a
field named like a class attribute, or conflicting body declarations all
raise TypeError at import time.
APIClient binds the backend, the base URL
and default headers, and send() runs the full pipeline — build the
request, send it, parse() the response:
from action0.client import APIClient
from action0.client.testing import StubBackend
from action0.req import Response
backend = StubBackend(Response(200, body='{"id": 42, "name": "Thing"}'))
client = APIClient(backend, "https://api.example.com/v1")
print(client.send(GetItem(item_id=42, expand=True)))
# Item(id=42, name='Thing')
print(backend.requests[0].url.as_str())
# https://api.example.com/v1/items/42?expand=true
print(backend.requests[0].headers["Accept"])
# application/json
And, the whole point: with an async or Twisted backend the same
operations yield Awaitable[Item] / Deferred[Item]:
from action0.client.testing import AsyncStubBackend
from action0.client.testing import DeferredStubBackend
async_client = APIClient(AsyncStubBackend(...), "https://api.example.com/v1")
item = await async_client.send(GetItem(item_id=42)) # Awaitable[Item]
twisted_client = APIClient(DeferredStubBackend(...), "https://api.example.com/v1")
deferred = twisted_client.send(GetItem(item_id=42)) # Deferred[Item]
deferred.addCallback(...)
Field placement¶
from typing import Any
from action0.client import JsonOperation
from action0.client import body
from action0.client import form_field
from action0.client import header
from action0.client import json_body
from action0.client import json_field
from action0.client import path_param
from action0.client import query
from action0.req import Method
class CreateItem(JsonOperation[Any]):
method = Method.POST
path = "/shelves/{shelf}/items"
shelf: str = path_param() # path template placeholder
dry_run: bool | None = query("dryRun", default=None) # renamed on the wire
locale: str | None = header("Accept-Language", default=None)
token: str = header("X-API-Key", repr=False) # kept out of repr()
name: str = json_field() # keys of the JSON body object
tags: list[str] | None = json_field(default=None)
All
json_field()s together form the JSON object body (withContent-Type: application/jsonadded if unset).json_body()sends one field — scalar, mapping, sequence, dataclass — as the entire JSON body instead.All
form_field()s together form anapplication/x-www-form-urlencodedbody — the classic HTML form POST and the shape of OAuth token endpoints. Values serialize exactly like query parameters:class RequestToken(JsonOperation[Any]): method = Method.POST path = "/oauth/token" grant_type: str = form_field(default="client_credentials") client_id: str = form_field() client_secret: str = form_field(repr=False) # body: grant_type=client_credentials&client_id=...&client_secret=... # Content-Type: application/x-www-form-urlencoded
body()sends one field as the raw body:bytes,stror a streamingBodyProducer.Only one of these body forms per operation (several
json_field()s or severalform_field()s or a singlejson_body()/body()), checked at class-creation time.
Serialization is uniform and overridable: None means “not sent” (except
for path parameters, which must not be None), enums send their
value, dates/datetimes their ISO form, booleans the web-style
true/false, and list values repeat the query parameter or header.
A serialize= callable on any field overrides the value’s serialization;
serialize_value / serialize_json_value on the operation override it
family-wide.
Fields without a specifier follow the operation’s default_location —
query parameters by default. An API family whose endpoints all POST JSON
can flip that once in a base class:
from typing import Any
from typing import ClassVar
from action0.client import JsonOperation
from action0.client.fields import Location
from action0.req import Method
class RpcOperation(JsonOperation[Any]):
"""Every endpoint of this API takes a JSON object body."""
method = Method.POST
default_location: ClassVar[Location] = Location.JSON_FIELD
class SearchProducts(RpcOperation):
path = "/search_product"
query: str # plain fields → JSON body keys, thanks to the base
limit: int = 30
Response handling¶
parse() = check() + load():
check()raisesAPIErrorfor anything but 2xx — override it for per-endpoint status policies (e.g. tolerate 404 and returnNone).load()turns the vetted response into the result.JsonOperationimplements it by decoding JSON and delegating toload_json(data); overrideload_jsonfor typed models (the default returns the decoded payload as-is, which fitsJsonOperation[Any]).
For non-JSON endpoints subclass
Operation directly and implement
load() — see DeletePet in
examples/petstore.py
for a 204-no-body endpoint.
A real client class¶
Applications usually wrap APIClient once per API, fixing base URL and
auth. Keep the backend type variable, so the typed overloads keep working
for every execution model:
from action0.client import APIClient
from action0.client import BackendT_co
class PetStoreClient(APIClient[BackendT_co]):
def __init__(self, backend: BackendT_co, token: str) -> None:
super().__init__(
backend,
"https://petstore.example.com/v1",
headers={"Authorization": f"Bearer {token}"},
)
Default headers fill gaps only — a header the operation sets itself wins.
For dynamic per-request work (signing, token refresh) override
prepare().
The complete, runnable version of this client — models, operations, client, all three execution models and the stub-backed demo — lives in examples/petstore.py.