The generated code¶
A tour of the package generated from the petstore schema (the exact package this repository pins in its tests). Everything is plain action0-client code — if you have hand-written operations before, there is nothing new to learn.
Models — models.py¶
Schema components become dataclasses; enums become enum.Enum
subclasses; each model gets a module-level converter function building
it from a decoded JSON payload (required keys via data[...], optional
ones via data.get(...), dates/UUIDs/enums/nested models converted).
The schema’s human-readable text survives: a component’s description
becomes the class docstring, a property’s description becomes a #:
doc-comment above its field (which Sphinx autodoc reads as the
attribute’s documentation):
@dataclass
class Pet:
"""One pet of the store."""
#: The pet's unique identifier.
id: int
name: str
#: The pet's sale status.
status: PetStatus | None = None
#: The pet's day of birth.
#: Unknown for pets rescued from the wild.
born_on: datetime.date | None = None # "bornOn" on the wire
friends: list[Pet] | None = None
def pet_from_json(data: Any) -> Pet: ...
A schema combining properties with additionalProperties gets a
catch-all field: the converter collects every payload key outside the
declared properties into a dict typed after the additionalProperties
schema — APIs answering with dynamic keys (Open-Meteo’s ensemble member
variables, for example) stay fully typed:
@dataclass
class HealthRecord:
"""One veterinary examination of a pet."""
clinic: str
#: The day of the examination. Null when it is not recorded.
examined_on: datetime.date | None = None
notes: str | None = None
#: Measured values by name (weight, temperature, ...).
additional_properties: dict[str, float] | None = None
# payload keys outside this set land in additional_properties
_HEALTH_RECORD_PROPERTIES = {"clinic", "examinedOn", "notes"}
def health_record_from_json(data: Any) -> HealthRecord: ...
The catch-all is a parse-side feature: serialized into a JSON request
body, the model would send it as a nested additional_properties
object, not flattened — leave it None there.
Operations — operations.py¶
One class per endpoint, method and path fixed per class, parameters and
body as typed fields (wire spellings preserved via the specifiers’
name argument), the response parsed into the model. The operation’s
summary and description form the class docstring; parameter and
body-property descriptions become #: doc-comments above their
fields:
class GetPet(JsonOperation[Pet]):
"""``GET /pets/{petId}``"""
method = Method.GET
path = "/pets/{pet_id}"
#: The identifier of the pet to operate on.
pet_id: int = path_param()
def load_json(self, data: Any) -> Pet:
return pet_from_json(data)
A 204 endpoint subclasses Operation[None], a non-JSON response
Operation[bytes]. A JSON result that needs no conversion — a plain
scalar, or an object without typed properties like an
additionalProperties: integer inventory — is returned through
typing.cast, since handing the decoded Any back as-is would fail
mypy strict:
class GetInventory(JsonOperation[dict[str, int]]):
"""``GET /store/inventory`` — Returns pet quantities by status."""
method = Method.GET
path = "/store/inventory"
def load_json(self, data: Any) -> dict[str, int]:
return cast(dict[str, int], data)
Error responses — errors.py¶
Documented 4xx/5xx (or 4XX/5XX/default) responses with a JSON
object schema become typed exceptions: one
action0.client.APIError subclass per (status, error model) pair,
named after the status, carrying the parsed payload as .error. The
operation overrides check to raise them; an error body that is not a
JSON object falls through to the plain APIError:
class NotFoundError(APIError):
"""Raised for the documented ``404`` answer, parsed into a :py:class:`Error`."""
def __init__(self, message: str, *, response: Response, error: Error) -> None: ...
class GetPet(JsonOperation[Pet]):
...
def check(self, response: Response) -> None:
if response.status == 404:
data = decode_error(response)
if isinstance(data, dict):
message = f"{type(self).__name__}: unexpected status {response.status}"
raise NotFoundError(
f"{message} {response.phrase}".rstrip(),
response=response,
error=error_from_json(data),
)
super().check(response)
The client — client.py¶
The security schemes become constructor credentials; an
apiKey-in-query scheme is added to every request in prepare():
class PetstoreClient(APIClient[BackendT_co]):
"""The Petstore API client."""
def __init__(
self,
backend: BackendT_co,
token: str,
api_key_auth: str,
base_url: str = "https://petstore.example.com/v1",
) -> None: ...
Using it¶
The client stays generic over the backend, so the execution model is your choice — sync, asyncio or Twisted, with the static types following along:
from action0.client.backends.requests import RequestsBackend
from petstore_client import GetPet, PetstoreClient
client = PetstoreClient(RequestsBackend(), token="...", api_key_auth="...")
pet = client.send(GetPet(pet_id=42)) # Pet
from action0.client.backends.httpx import AsyncHttpxBackend
client = PetstoreClient(AsyncHttpxBackend(), token="...", api_key_auth="...")
pet = await client.send(GetPet(pet_id=42)) # Awaitable[Pet]
Non-2xx responses raise action0.client.APIError with the request and
response attached — a documented error status raises the generated
subclass from errors.py with the parsed payload on top:
from petstore_client import NotFoundError
try:
pet = client.send(GetPet(pet_id=999))
except NotFoundError as error: # the documented 404, parsed
print(error.error.code, error.error.message)
Transport problems arrive as TransportError / TimeoutError — see
the action0-client error
guide.
Testing your integration¶
The stub backends of action0.client.testing drive generated clients
without a server, exactly like the tests of this repository drive the
pinned petstore package:
from action0.client.testing import StubBackend
from action0.req import Response
backend = StubBackend(
Response(200, headers={"Content-Type": "application/json"}, body=b'{"id": 1, "name": "Rex"}')
)
client = PetstoreClient(backend, token="t", api_key_auth="k")
pet = client.send(GetPet(pet_id=1))
print(pet.name) # Rex
print(backend.requests[0].url.path) # /v1/pets/1