apiroute
Build API clients declaratively: one @route per endpoint, safe defaults, httpx underneath. A Python port of the Ruby gem client-api-builder.
Installation
pip install apiroute # or: uv add apiroute
pip install apiroute[pydantic] # to return pydantic models
pip install apiroute[codegen] # to generate clients from OpenAPI/Swagger specs and WSDLs
pip install apiroute[soap] # to run generated SOAP clients
apiroute needs Python 3.10 or newer, and httpx.
Quick start
from apiroute import Client, Query, Retries, Section, route
class GitHubApi(Client):
base_url = "https://api.github.com"
headers = {
"Accept": "application/vnd.github.v3+json",
"Authorization": lambda self: f"Bearer {self.access_token}",
}
retries = Retries.exponential(attempts=5, initial=0.1, max=10, jitter=True)
def __init__(self, access_token: str, org: str, **kwargs):
super().__init__(**kwargs) # base_url=..., transport=...
self.access_token = access_token
self.org = org
@route("/users/{username}") # GET, inferred from the name
def get_user(self, username: str) -> dict: ...
@route("/users/{username}/repos", query={"per_page": "{per_page}"})
def get_repos(self, username: str, per_page: int = 30) -> list: ...
# POST, inferred; {org} comes from self.org
@route("/orgs/{org}/repos", body={"name": "{name}", "private": "{private}"})
def create_org_repo(self, name: str, private: bool = False) -> dict: ...
@route("POST", "/uploads")
def create_upload(self, body: dict) -> dict: ...
class issues(Section):
inherit = ("headers",)
@route("/repos/{owner}/{repo}/issues")
def list(self, owner: str, repo: str, state: Query = None) -> list: ...
gh = GitHubApi(access_token="ghp_...", org="acme")
gh.get_user("octocat")
gh.issues.list("acme", "api", state="open")
gh.with_options(timeout=5, retries=1).get_repos("octocat")
Routes
@route([method,] path, query=..., body=..., expected=..., returns=..., timeout=..., stream=...)
- Method inference. A name that is, or starts with,
post,create,addorinsertfollowed by_is POST.put,update,modifyandchangeare PUT,patchis PATCH, anddelete,removeanddestroyare DELETE. Anything else is GET. - Placeholders. Write
{name}in paths, query values and body values (paths also take:name). The value comes from the argument with that name, or else from the client's attribute or method. A string that is exactly"{name}"keeps the value's type. - Other arguments must be annotated with
Query,Query("name"),Header("X-Name")orBody. The exception is a parameter namedbodyon a route without a body template. Otherwise the class fails to build withRouteDefinitionError. - Returns. Parsed JSON by default (
Nonefor an empty body), orreturns="text" | "bytes" | "response". - Errors. Any status outside
expected(any 2xx when it's unset) raisesUnexpectedResponse, which carries the.response. - Falling back to the client. A parameter whose default is
FROM_CLIENTtakes the client's attribute of the same name when the caller leaves it out:def list_members(self, org: str = FROM_CLIENT).
Response hook
Give the method a keyword-only parameter annotated Result and its body runs after the request. The parameter receives the processed result (parsed JSON, or whatever returns asks for), and a value the body returns replaces it. Returning None keeps the result. Callers never see the parameter.
@route("/users/{username}")
def get_login(self, username: str, *, data: Result) -> str:
return data["login"]
gh.get_login("octocat") # "octocat"
Streaming
stream= |
Destination | Returns |
|---|---|---|
"file" |
the path in a file parameter, written wb |
the httpx.Response |
"io" |
the writable object in an io parameter, left open |
the httpx.Response |
"iter" |
none | a ChunkIterator of bytes (an AsyncChunkIterator through .aio) |
@route("/files/{id}", stream="file")
def download(self, id: int, file: str): ...
with gh.get_events() as events: # stream="iter"; the with block closes the connection
for chunk in events:
...
- The status is checked before any of the body is read. An error response raises
UnexpectedResponsewith its body readable, and nothing is written: an existing file is left untouched. - File paths with a null byte or a
..segment are rejected before the request is sent, as in the Ruby gem. Absolute paths are allowed. - Opening the stream is retried like any request; a failure while the body is being read is raised, not retried.
- In async calls, an
ioobject whosewritereturns an awaitable (such as an aiofiles file) is awaited.
Typed returns
A return annotation that contains a dataclass or a pydantic model converts the JSON response. That includes -> User, -> list[User], -> dict[str, User] and -> User | None. Routes without a def take model= instead. Plain annotations (dict, list, Any) leave the JSON as parsed.
@route("/users/{username}")
def get_user(self, username: str) -> User: ...
list_users = route("/users", model=list[User])
- Pydantic models are validated with pydantic's
TypeAdapter, with its usual type coercion. Install it withpip install apiroute[pydantic]. - Dataclasses are built by apiroute itself, so they don't need pydantic. Keys the dataclass doesn't declare are ignored, and a missing required field fails.
- Errors. A body that doesn't fit raises
ResponseValidationError, which carries the.responseand the underlying.error. - With a response hook, the
Resultparameter's type is used (user: Annotated[User, Result()]). The return annotation then describes what the hook returns. - Late-defined types. A model defined below the client class (
-> "Team") is looked up on the first call. - Other return modes. Only JSON is converted;
returns="text","bytes","response"and streams are left alone.
Routes without a def
For simple endpoints, assign the route and let the class generate the method, as the Ruby gem does:
class GitHubApi(Client):
org: str # declared, so {org} falls back to self.org
get_user = route("/users/:username") # (*, username)
list_repos = route("/orgs/{org}/repos", query={"page": "{page}"}) # (*, org=FROM_CLIENT, page)
create_gist = route("/gists") # (*, body=None), POST
gh.get_user(username="octocat")
gh.list_repos(page=2)
- Every placeholder becomes a keyword-only parameter, in path, then query, then body order.
- A name the class declares (a class attribute, method, property or annotation such as
org: str) is optional and falls back to the client. In a section, declare it on the section; the value still comes from the root client. - POST, PUT and PATCH routes without a body template take an optional
body. help(),inspect.signatureand wrong-argumentTypeErrors work. Editors and type checkers can't see a signature made at runtime, though, so prefer adefwhere autocomplete and type checking matter.
Namespaces
Routes declared inside a namespace block get its path prefix, as in the gem. Blocks nest.
class Api(Client):
with namespace("/admin"):
list_users = route("/users") # GET /admin/users
@route("/users/{id}")
def delete_user(self, id: int): ... # DELETE /admin/users/{id}
Per-request options
client.with_options(headers=..., query=..., body=..., timeout=..., retries=..., returns=...) returns a view of the client whose route calls also send these options. Calls can be chained.
headersandquerymerge over the class and route values. ANonevalue leaves that header or param out.bodyis shallow-merged over the route's body when both are dicts, and aNonevalue removes that key. Any other body (a list or a string, or when the route has no dict body) replaces the route's body.
@route("/users", body={"user": {"name": "{name}"}, "notify": True})
def create_user(self, name: str) -> dict: ...
client.with_options(body={"notify": None, "source": "import"}).create_user("Ann")
# sends {"user": {"name": "Ann"}, "source": "import"}
Sections
A nested class that subclasses Section groups routes under the client, as client.<class name>:
class Api(Client):
base_url = "https://api.example.com"
headers = {"Authorization": lambda self: f"Bearer {self.token}"}
class users(Section):
inherit = ("headers",) # use the client's headers too
list = route("/users")
class uploads(Section):
base_url = "https://uploads.example.com" # its own host
headers = {"Authorization": lambda self: f"Upload {self.upload_token}"}
create = route("/files")
api.users.list()
api.uploads.create(body=data)
- A section uses its parent's
base_urlunless it sets one. It inherits nothing else unless listed ininherit("headers","query_params"), as in the gem. {name}placeholders, callable headers, the overridable methods and the transport all belong to the root client, so a section can use the client's state.- Sections nest, are created once per client, and remain reachable as classes (
Api.users).
Retries
retries = Retries(attempts=3, sleep=0.5) # 3 attempts in total, 0.5 s apart
retries = Retries.exponential(attempts=5, initial=0.1, max=10) # 0.1, 0.2, 0.4, 0.8 s
retries = Retries(attempts=5, sleep=0.1, backoff=2, max_sleep=10, jitter=True)
attemptsis the total number of attempts. The default is one, so nothing is retried until you configure it.- The sleep before retry n is
sleep * backoff ** (n - 1), capped atmax_sleep.jitter=Truesleeps a random time up to that; a number from 0 to 1 shortens it by up to that fraction. - Only timeouts, network errors and protocol errors are retried, unless
should_retrysays otherwise. A retriedUnexpectedResponsewith aRetry-Afterheader waits that long, capped atmax_sleep. with_options(retries=3)changes the attempts for one call;with_options(retries=Retries(...))replaces the policy.
Errors
| Exception | Raised when | Carries |
|---|---|---|
RouteDefinitionError |
a route is wrong (an unused parameter, a bad template value or option); raised when the class is created | the class, route and reason in its message |
UnexpectedResponse |
the status isn't expected (any 2xx when expected is unset), or a JSON route's body isn't JSON |
.response, .status_code, .request, .route_name |
ResponseValidationError |
the JSON doesn't fit the route's model | .response, .error, .route_name |
httpx.TransportError subclasses |
the network fails, after retries run out | httpx's details |
ValueError |
a class setting is invalid (a base_url scheme, body_format, inherit), or a streamed file path is unsafe |
the reason in its message |
The first three derive from ApiRouteError. client.last_response holds the most recent response for debugging; it is per client, so don't rely on it when one client is shared between threads.
Class settings
| Attribute | Default |
|---|---|
base_url |
none; http or https only |
headers, query_params |
{}. Values may be callables taking the client. Both merge down subclasses, and None removes a key |
timeout |
30 seconds |
retries |
Retries() (one attempt) |
body_format |
"json", "form" or callable(client, data) |
query_format |
"flat", "nested" (a[b]=1) or callable(client, data) returning pairs |
Methods to override
| Method | Runs | Default |
|---|---|---|
on_request(self, request) |
before every attempt, sync or async; may return a changed RequestSpec |
sends the request as is |
on_response(self, request, response, elapsed) |
after every response, whatever its status; for streams, before the body is read | nothing |
should_retry(self, error, attempt) |
when an attempt fails | retries httpx timeouts, network and protocol errors |
escape_path(self, value) |
for every path value | percent-encodes everything but A-Z a-z 0-9 - . _ ~ |
soap_header(self, operation) |
when building a SOAP envelope; returns a dict for its Header | no Header |
request.operation names the call: the route's operation= (generated clients set it to the spec's operationId or WSDL operation name), else the method name. It's useful for logging, metrics and APIs that want the call name in the request.
class SignedApi(Client):
def on_request(self, request):
signature = hmac.new(self.secret, request.url.encode(), "sha256").hexdigest()
return dataclasses.replace(request, headers={**request.headers, "X-Signature": signature})
def on_response(self, request, response, elapsed):
metrics.timing("api.request", elapsed, tags=[f"status:{response.status_code}"])
Network errors don't reach on_response; every failed attempt is logged on the apiroute logger, with the route, the attempt number and the error.
Transports: how requests are sent
An API class only describes the API. How its requests reach the network is a transport, chosen for the environment at startup rather than baked into the class. The same GitHubApi can open a fresh connection per request in a web server and keep a pool per thread in a background worker.
| Transport | Connections | Suits |
|---|---|---|
OneShotTransport (default) |
a new one per request, closed once the response is read | web servers, forking process managers, low request volume |
PooledTransport |
one httpx.Client (one pool) shared by all threads |
many threads or short-lived threads, HTTP/2 |
ThreadLocalTransport |
one httpx.Client per thread, closed when the thread ends |
a fixed set of worker threads |
HttpxClientTransport |
an httpx.Client you own |
tests (with httpx.MockTransport), custom setups |
Each takes httpx.Client keyword arguments, such as limits, http2=True, proxy and verify. The SSL context is built once per transport, so a one-shot request costs a new connection but not a reload of the CA bundle. Pooled and thread-local transports open new clients in a forked child.
import httpx
from apiroute import (
HttpxClientTransport,
PooledTransport,
ThreadLocalTransport,
close_transports,
use_transport,
)
# worker.py: each worker thread keeps its own connections
use_transport(ThreadLocalTransport())
# or one shared pool for one API, with HTTP/2
use_transport(PooledTransport(http2=True, limits=httpx.Limits(max_connections=20)), GitHubApi)
# one instance, e.g. in a test
gh = GitHubApi("token", "acme", transport=HttpxClientTransport(httpx.Client(transport=mock)))
close_transports() # at shutdown
A client uses its own transport= argument if it was given one. Otherwise it uses the transport registered for its class or the nearest base class, and otherwise the process default. Sections use their root client's transport. A custom transport is any object with send(spec) -> httpx.Response, stream(spec) (a context manager yielding an unread httpx.Response, needed only for streaming routes) and close().
Async
The same client class works with await: client.aio is a view whose route calls return coroutines.
user = gh.get_user("octocat") # sync
user = await gh.aio.get_user("octocat") # async, same class and routes
issues = await gh.aio.issues.list("acme", "api") # sections too
await gh.aio.with_options(timeout=5).get_user("a") # with_options, in either order
Async calls have transports of their own, registered beside the sync ones. use_transport sends an async transport (one whose send is a coroutine) to the async side, so configuring one side never changes the other.
| Transport | Connections |
|---|---|
AsyncOneShotTransport (default) |
a new one per request |
AsyncPooledTransport |
one httpx.AsyncClient per event loop (an AsyncClient can't be shared across loops) |
AsyncHttpxClientTransport |
an httpx.AsyncClient you own |
use_transport(AsyncPooledTransport(http2=True)) # async default for every client
gh = GitHubApi("token", "acme", async_transport=AsyncHttpxClientTransport(async_client))
await aclose_transports() # at shutdown, inside the event loop
Retries sleep with asyncio.sleep. should_retry, escape_path, on_request, on_response and callable headers stay plain sync methods, shared by both sides.
Code generation
apiroute generate writes a client package from an OpenAPI 3.0/3.1 or Swagger 2.0 spec, or from a WSDL 1.1 document (document/literal, SOAP 1.1 and 1.2). Specs can be file paths or URLs, in JSON or YAML.
apiroute generate openapi github.yaml -o gh --client-name GitHubApi # dict routes only
apiroute generate openapi github.yaml -o gh --client-name GitHubApi --models # plus pydantic models
apiroute generate wsdl "https://example.com/weather?wsdl" -o weather --models
The package has two layers:
from gh import GitHubApi
class MyGitHub(GitHubApi): # yours, outside gh/: regenerating never touches it
headers = {"Authorization": lambda self: f"Bearer {self.token}"}
def __init__(self, token: str, **kwargs):
super().__init__(**kwargs)
self.token = token
gh = MyGitHub("ghp_...")
gh.meta.get() # each tag is a section; dicts in and out
gh.repos.get("octocat", "hello-world")
gh.repos.models.get("octocat", "hello-world") # -> FullRepository (with --models)
await gh.repos.models.aio.get("octocat", "hello-world")
gh/
client.py GitHubApi: untagged routes; each tag a lazily imported section
models/ with --models
client.py gh.models: typed versions of the root routes
models.py a lazy index of the models
schemas/*.py one module per model (models that refer to each other share one)
repos/
client.py gh.repos: the tag's dict routes
models/
client.py, models.py, schemas/*.py
Nothing is imported until it's used. On GitHub's spec (1,242 operations, 4,035 models), constructing the client takes 0.6 ms, a dict call about 3 ms all in, and the first typed call costs pydantic's own import plus a few milliseconds for the models it returns.
-
Your code goes outside the package. Subclass the client to add behaviour, such as auth a spec doesn't declare (above); headers merge down subclasses. Regenerating removes only files the generator wrote, and refuses to write into a directory it didn't create.
-
Auth can live in a hand-written base class.
--base-class mypackage.auth:MyAuthmakes the generated root client extendMyAuth(aClientsubclass) instead ofClient, and leaves out the spec's security schemes. The base class owns credentials, throughheaders,query_params,on_requestor, for SOAP,soap_header, and regenerating never touches it:class TradingAuth(Client): # eBay Trading: credentials in the SOAP header, call name in the query def __init__(self, token: str, **kwargs): super().__init__(**kwargs) self.token = token def soap_header(self, operation): return {"RequesterCredentials": {"eBayAuthToken": self.token}} def on_request(self, request): return dataclasses.replace( request, params=[*request.params, ("callname", request.operation)] )
-
Output passes
ruff checkandruff format. When ruff is installed, the generator runs it over the output;--no-formatskips that. -
OpenAPI: tags become sections,
operationIds become method names, and path, query and header parameters become arguments. Bodies can be JSON, form or multipart; responses can be JSON, text or binary. Schemas become models, withallOfmerged,oneOf/anyOfas unions and enums asLiteral. Bearer, basic and API key auth become constructor arguments. -
WSDL: each operation becomes a POST route with its
SOAPAction. Bodies are dicts in the xmltodict shape: child elements as keys,"@attr"for attributes,"#text"for text content, and"{namespace}local"for an element from another namespace. Faults raiseapiroute.soap.SoapFault. XML is parsed withdefusedxml.
Security
- Base URLs must be
httporhttpsand have a host. This is checked when the class is created and when abase_urlis passed to a client, which keeps a misconfiguration from reachingfile:and similar schemes. - Path values are percent-encoded so each stays one path segment:
get_file(name="a/b c")requests/files/a%2Fb%20c, so a value can never add..or a segment. Overrideescape_pathto allow/. - URLs are joined, not resolved: the request URL is
base_urlplus the path, so a route can't replace the host. - TLS is verified by default (httpx). Turning it off takes an explicit
verify=Falseon a transport. - Streamed files are only opened once the status has passed, and paths with a null byte or a
..segment are rejected.
Thread safety
- Route definitions are compiled once, when the class is created, and never change afterwards.
PooledTransportis safe to share between threads, andThreadLocalTransportgives each thread its own connections.OneShotTransportshares nothing.- A client instance holds no connections, so one instance can be shared between threads. The exception is
last_response, which is per instance and changes with every request. namespaceblocks are tracked per thread.
Development
uv sync
uv run pytest
uv run ruff check .
Metadata
Release files for apiroute 1.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| apiroute-1.2.0.tar.gz | 140.4 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| apiroute-1.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 248.4 kB
Release files / apiroute-1.2.0.tar.gz
| Download URL | apiroute-1.2.0.tar.gz |
|---|---|
| Size | 140.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
81f82dde3127bc9e3fe16c0ee383abf5d08d9f244cb3d89c640e6f2b5b9ec3af
|
|
BLAKE2b-256 checksum How to use checksums |
66b17aef1f1cde640d0de306d61d95fef9b4428916a66e08f82dc062d888bfaa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / apiroute-1.2.0-py3-none-any.whl
| Download URL | apiroute-1.2.0-py3-none-any.whl |
|---|---|
| Size | 108.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
402902d9620e8dbf5cb1e176ae438516073a5722dbfb93518a2c926938d28201
|
|
BLAKE2b-256 checksum How to use checksums |
d5c5b4be136e842a4c0a8949727cbf9cbf4c9e6b637533a3c8fe3e1dafd66e21
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for apiroute 1.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.