REST-RPC is a Python library that makes writing type-checked REST APIs easy (by allowing you to define your API once and use it on both the server and the client).
It automatically creates convenient front-end bindings to your API for you, so from the front-end developer's perspective it's indistinguishable from an RPC library, hence the name.
REST-RPC's type-checking is based on Pydantic. For the back-end, it used FastAPI. For the front-end, it supports requests, urllib3, httpx, and aiohttp (or provide your own transport layer). If you want to use REST-RPC in the webbrowser, you can: It supports pyodide's pyfetch and pyscript's fetch!1
REST-RPC is for you, if you:
- …like FastAPI and Pydantic.
- …just want to write simple type-checked REST APIs without frills.
- …don't want to repeat yourself when writing the front-end code.
Tip
Go right to the documentation
To install rest-rpc for back-end use, run:
$ uv add rest-rpc --extra fastapiTo install rest-rpc for front-end use, run:
$ uv add rest-rpc --extra requestsIf you want to use urllib3, httpx, or aiohttp instead of requests, just replace the corresponding extra.
We assume that you're familiar with REST APIs and FastAPI.
See examples/simple/ for a simple example of an API definition, back-end, and front-end.
The core idea behind REST-RPC is that an API definition is shared between back-end and front-end, so we begin by defining an API without any implementation details:
# my_api.py
from typing import Annotated, Any
from rest_rpc import ApiDefinition, Query
api_def = ApiDefinition()
@api_def.get("/")
def read_root() -> dict[str, str]: ...
@api_def.get("/items/{item_id}")
def read_item(
item_id: int, q: Annotated[str | None, Query()] = None
) -> dict[str, Any]: ...You've probably seen something similar on the FastAPI homepage.
Let's continue with the actual back-end definition:
# main.py
from rest_rpc import ApiImplementation
from my_api import api_def
api_impl = ApiImplementation(api_def)
@api_impl.handler
def read_root():
return {"Hello": "World"}
@api_impl.handler
def read_item(item_id, q):
return {"item_id": item_id, "q": q}
app = api_impl.make_fastapi()And finally, the front-end:
# client_sync.py
from rest_rpc import ApiClient
from my_api import api_def
def main() -> None:
api_client = ApiClient(api_def, engine="requests", base_url="http://127.0.0.1:8000")
result = api_client.read_root()
print(result)
assert result == {"Hello": "World"}
result2 = api_client.read_item(item_id=42, q="Foo")
print(result2)
assert result2 == {"item_id": 42, "q": "Foo"}
if __name__ == "__main__":
main()You can try it out by running uv run fastapi dev in one terminal and running uv run client_sync.py in another.
For the sake of the comparison, here is what a functionally identical back-end and front-end would look like without REST-RPC:
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"Hello": "World"}
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}# client.py
import requests
def main() -> None:
response = requests.get("http://127.0.0.1:8000/")
response.raise_for_status()
result = response.json()
print(result)
assert result == {"Hello": "World"}
response2 = requests.get("http://127.0.0.1:8000/items/42", params={"q": "Foo"})
response2.raise_for_status()
result2 = response2.json()
print(result2)
assert result2 == {"item_id": 42, "q": "Foo"}
if __name__ == "__main__":
main()Let's go through it from top to bottom:
The API definition in my_api.py looks very similar to the way we write FastAPI apps. Some notable differences are:
- All the route definitions are just stubs (they're missing their bodies). This is intentional: Since
my_api.pyis imported by both the back-end and the front-end, we want to hide the implementation details of the server from the client. The client only needs to know which routes exist, and not how they are implemented. The implementation can be found insidemain.pyinstead. - Instead of the
@app.get()decorator we use theapi_def.get()decorator which works mostly the same. - There are a few annotations more: The return values of the routes are annotated, and we use
Annotated[]together withQuery()to annotate thatqis a query parameter. These annotations are required in REST-RPC. In FastAPI, you can use them, but don't have to.
The API implementation is contained in main.py. Again, this looks similar to the pure FastAPI version. Here, the annotations and default values are not required. We left them out to keep the code brief and to show that it's supported. Once your API design is stable, you'll probably want to add them.
Finally, client_sync.py contains a simple front-end. The actual API calls are very neatly abstracted away, i.e. since we stated in my_api.py that a function read_item(item_id: int, q: str | None = None) -> dict[str, Any] exists, we can just call api_client.read_item(item_id=42, q="Foo") in the front-end. On the other hand, the front-end written with pure requests looks way less pleasing to the eye. One could of course argue that the example is intentionally ugly and that it would be obvious to write an abstraction layer that builds the URL from a constant base_url, that checks return codes and that actually validates the API's reponses. But REST-RPC is that abstraction layer!
As a final note about this example: Here, the API client uses requests internally. If you want to use httpx or urllib3 instead, just pass a different engine to the ApiClient constructor. From the user perspective it doesn't matter. The function calls always behave the same way. So use the engine you like working with. If you need help deciding, maybe the performance comparison will help.
In the simple example above, the requests are performed synchronously. If you're working with an async environment, you'll want to take a look at client_async.py inside examples/simple/:
# client_async.py
import asyncio
import aiohttp
from rest_rpc import ApiClient
from my_api import api_def
async def main() -> None:
async with aiohttp.ClientSession() as session:
api_client = ApiClient(
api_def, engine="aiohttp", session=session, base_url="http://127.0.0.1:8000"
)
result = await api_client.read_root()
print(result)
assert result == {"Hello": "World"}
result2 = await api_client.read_item(item_id=42, q="Foo")
print(result2)
assert result2 == {"item_id": 42, "q": "Foo"}
if __name__ == "__main__":
asyncio.run(main())When using the aiohttp engine, you'll need to construct the aiohttp.ClientSession yourself and pass it to the ApiClient constructor. If you're wondering why this is necessary, take a look in the aiohttp FAQ.
The accessor functions are now async as well, so you'll need to await them.
Akin to FastAPI's Example Upgrade, we also provide an upgrade of our own simple example above which can be found in examples/upgraded:
# my_api.py
from typing import Annotated, Any
from rest_rpc import ApiDefinition, Query, Body
from pydantic import BaseModel
api_def = ApiDefinition()
class ReadItemResponse(BaseModel):
item_id: int
q: str | None
class Item(BaseModel):
name: str
price: float
is_offer: bool | None = None
class UpdateItemResponse(BaseModel):
item_name: str
item_id: int
@api_def.get("/")
def read_root() -> dict[str, str]: ...
@api_def.get("/items/{item_id}")
def read_item(
item_id: int, q: Annotated[str | None, Query()] = None
) -> ReadItemResponse: ...
@api_def.put("/items/{item_id}")
def update_item(item_id: int, item: Annotated[Item, Body()]) -> UpdateItemResponse: ...# main.py
from rest_rpc import ApiImplementation
from my_api import api_def, ReadItemResponse, UpdateItemResponse
api_impl = ApiImplementation(api_def)
@api_impl.handler
def read_root():
return {"Hello": "World"}
@api_impl.handler
def read_item(item_id, q):
return ReadItemResponse(item_id=item_id, q=q)
@api_impl.handler
def update_item(item_id, item):
return UpdateItemResponse(item_name=item.name, item_id=item_id)
app = api_impl.make_fastapi()# client_sync.py
from rest_rpc import ApiClient
from my_api import api_def, ReadItemResponse, UpdateItemResponse, Item
def main() -> None:
api_client = ApiClient(api_def, engine="requests", base_url="http://127.0.0.1:8000")
result = api_client.read_root()
print(result)
assert result == {"Hello": "World"}
result2 = api_client.read_item(item_id=42, q="Foo")
print(result2)
assert result2 == ReadItemResponse(item_id=42, q="Foo")
result3 = api_client.update_item(item_id=42, item=Item(name="pie", price=3.14))
print(result3)
assert result3 == UpdateItemResponse(item_name="pie", item_id=42)
if __name__ == "__main__":
main()Here, we make generous use of pydantic's BaseModel. This is the recommended way, so instead of returning dict instances, you should always aim for BaseModel instead. This makes the intent of the API much clearer and makes type-checking more robust.
Pyodide and PyScript are two methods to use Python in the webbrowser. They both have wrappers around the browser's Fetch API and REST-RPC supports both of them. Just use engine="pyodide" or engine="pyscript". See the examples/webapp directory for a complete minimal example webapp in which the front-end is served via FastAPI.
If you're not happy of the selection for the engine parameter, you can also provide your own. Just pass engine="custom" and your hand-crafted transport function.
This is useful if you already have an HTTP abstraction, want to integrate with a test harness, or need special authentication logic.
Here are some notable differences between building your REST API with pure FastAPI and using REST-RPC:
- With REST-RPC, you need to add an annotation to every parameter and the return value of routes. With FastAPI, this is not strictly required and only needed when you want the type-checking.
- FastAPI has some rules to automatically infer if a parameter is supposed to be a path, query, or body parameter which can be found in the FastAPI tutorial. You can also annotate parameters with
Path(),Query(), orBody()to make it explicit. REST-RPC does not do such automatic inference. You have to annotate query parameters withQuery()and body parameters withBody(). Only path parameters are allowed to not have such an annotation. REST-RPC provides its own versions ofQuery(),Body()etc. which are mapped to FastAPI's versions when creating a FastAPI app, but they do support less features than the FastAPI versions to simplify things. - REST-RPC only supports Body parameters for
PATCH,PUT, andPOST, not forGETandDELETE. Furthermore, only one Body parameter is supported, and FastAPI'sBody(embed=True)is not supported. - Out of FastAPI's Request Parameters, REST-RPC only supports
Path,Query,Body, andHeader, notCookie,Form, orFile.
Another important note: In the API implementation, you don't need to add any annotations or default values to the route handlers. So usually, the only things that strictly have to match are the names of the route handler functions and their parameters. So this is okay:
@api_def.get("/foo")
def foo(bar: int = 42, baz: Annotated[str | None, Query()] = None) -> dict[str, Any]: ...
# [...]
@api_impl.handler
def foo(bar, baz):
return {"bar": bar, "baz": baz}However, if you decide to add annotations or default values, they do have to match exactly (except for Annotated[], where you must use the first argument instead), so these are all okay:
@api_impl.handler
def foo(bar: int, baz: str | None):
return {"bar": bar, "baz": baz}
@api_impl.handler
def foo(bar: int = 42, baz: str | None = None):
return {"bar": bar, "baz": baz}But these are not:
@api_impl.handler
def foo(bar: float, baz: int):
return {"bar": bar, "baz": baz}
@api_impl.handler
def foo(bar = 0, baz = "Baz"):
return {"bar": bar, "baz": baz}Tip
Personal recommendation: Keep it simple in the prototyping phase since things are likely to be changed around and you'll be annoyed by changing the signatures in two places instead of one. Once the API definition becomes stable, you can still add the annotations to the API implementation to improve expressivity.
The benchmark directory contains a very simple benchmarking script, where we compare the performance of the different engines on a simple get("/") without parameters. Your mileage may vary in other situations.
As you can see below, httpx performs noticeably worse in this specific benchmark.
So it's probably a good idea to use requests or urllib3 if you want synchronous requests, and of course aiohttp when you prefer async.
- Support for authentication. At the moment, you can only do this via middlewares in the backend implementation.
- Support parameters in
ApiImplementation.make_fastapi()that are forwarded to theFastAPI()constructor. - Missing something? Create an issue.
Footnotes
-
This means that the only "hard" dependency is
pydantic. Of course, you'll need FastAPI in the back-end and one of the mentioned HTTP libraries for the front-end. ↩
