Building Pyrseia II: Fleshing out Clients and Servers
This is the second article in the Pyrseia series. The others are:
- Building Pyrseia I: The Idea
- Building Pyrseia III: Server Middleware, Client Senders, CLI and InApp Validators
If you want to follow along with the code, this article refers to commit 2db40614bd926d1ef2854669a634fcfa3ba25502.
Decoupling the API from the Client
The last time we checked in with our small Calculator project, the client and API parts were intertwined, and looked like this:
@client
class CalculatorRpc:
@rpc
async def add(self, a: int, b: int) -> int:
...
Since the server needs to depend on the API too, but we don't want the server to depend on the client, let's split up the API into a separate class.
api.py
:
from pyrseia import rpc
class Calculator:
@rpc
async def add(self, a: int, b: int) -> int:
...
client.py
:
from pyrseia import create_client
from pyrseia.httpx import httpx_client_adapter
async def create_calculator_client(url: str) -> Calculator:
return await create_client(httpx_client_adapter(url))
Off the bat, creating a client is an async operation now, because the client might need to initialize using the event loop.
Connecting the Client to the network
Creating a client returns a subclass of the API that you can actually use to perform calls. Ideally, we would use Python's type system to stop you accidentally calling the API class directly (i.e. calling api.Calculator().add(1, 2)
rather than client.add(1, 2)
), but I haven't figured out how to do this yet. (Abstract base classes don't quite fit due to Mypy limitations.)
Following a component-based approach, creating a client requires a client network adapter. The exact type of this argument is AsyncContextManager[ClientAdapter]
, and ClientAdapter
is just a type alias for Callable[[bytes], Awaitable[bytes]]
. This looks scary, so let's untangle it.
Essentially, the client needs a component that it can give bytes to and await it to get bytes back. This is exactly described with Callable[[bytes], Awaitable[bytes]]
. If you had an instance of this type, you'd use it like this:
async def use(something: Callable[[bytes], Awaitable[bytes]]):
res = await something(b"")
# res is an instance of bytes.
As we mentioned, this is aliased as (pyrseia.)ClientAdapter
to save on typing (pun intended).
So what's the deal with this AsyncContextManager[ClientAdapter]
then? Async context managers are basically a more convenient way of making async factories. The client doesn't just need a ClientAdapter
, it needs a way to create, asynchronously, a client adapter on every request. If someone handed you an instance of AsyncContextManager[ClientAdapter]
, here's how you'd use it:
async def use_again(something: AsyncContextManager[ClientAdapter]):
async with something as client_adapter:
res = await client_adapter(b"")
# res is bytes.
Now, maybe not every network library needs to async create a client on every request, but this is a good lowest common denominator. Using this API, we can basically adapt any network protocol library to our clients. Within reason.
I've written two network adapters for our clients: pyrseia.httpx.httpx_client_adapter
and pyrseia.aiohttp.aiohttp_client_adapter
. Here's the source of the httpx adapter, just for you to get a taste (imports elided):
def httpx_client_adapter(
url: str, timeout: Optional[int] = None
) -> AsyncContextManager[ClientAdapter]:
@asynccontextmanager
async def adapter() -> AsyncGenerator[ClientAdapter, None]:
async with AsyncClient(timeout=timeout) as client:
async def sender(payload: bytes) -> bytes:
res = await client.post(url, data=payload)
return res.content
yield sender
return adapter()
I'm not going to claim this is super simple - there's a lot of complexity in these few lines of code. There's also a lot of power. Hopefully it could be a good starting point if you want to write your own someday. (As long as we keep this API 😉.)
Again, with the proper type annotations, Mypy can check all of this. If your sender, for example, takes a str
instead of bytes
by accident, Mypy will disallow it.
So, given a server URL, we can create and use the client to access our remote Calculator
implementation. And we can pick between two HTTP libraries to do so! There's also an async pyrseia.close_client
you're supposed to await to gracefully close your clients.
Servers and Request Contexts
The server component has become generic with respect to two types: the actual API that it's implementing, and a request context class.
A request context is metadata that's specific to every instance of an incoming request. A simple example of request metadata is the remote IP address of the client, although that's HTTP/TCP specific. For example, if the server was connected to a message bus (like Redis pubsub or Kafka) instead of an HTTP endpoint, the request context might not have this particular piece of information.
Since we want the server to be very versatile, we let the creator of the server pick the exact request context class.
So let's say you decide to expose a server for the Calculator
interface using HTTP, and you want to use Starlette to be the bridge to the outside world (or the source of requests, depending on how lyrical you're feeling). A good choice for a request context class would then be starlette.requests.Request
.
So how does your code use the request context? When you write an implementation for an @rpc
endpoint, the first argument to your coroutine can, optionally, be an instance of the request context class. So you could write:
server.py
:
from pyrseia import server
from starlette.requests import Request
from .api import Calculator
serv = server(Calculator, ctx_cls=Request) # type: Server[Calculator, Request]
@serv.implement(Calculator.add)
async def add(ctx: Request, a: int, b: int) -> int:
print(ctx.client) # Print the IP address and port of the requester.
return a + b
This is also checked by Mypy!
So how do we actually serve our server? Like this.
app.py
:
from pyrseia.starlette import create_starlette_app
from .server import serv
app = create_starlette_app(serv)
and use Uvicorn to start it:
$ uvicorn app:app
create_starlette_app
takes a server of type Server[Any, starlette.requests.Request
, so Mypy will also make sure you connect these together properly, if you use it. You won't be able to create a Starlette app from a server that expects an aiohttp request.
Speaking of aiohttp, I've also written a helper to create an aiohttp app (pyrseia.aiohttp.create_aiohttp_app
) so you have your choice of server tech. Aiohttp apps aren't ASGI apps so they can't be run with Uvicorn, though.
You might want to use your own class for the request context though. Easy bridging what your network runners provide and what you want will be one of the next steps for Pyrseia.
Does this actually work, though?
It does! I've written tests in the repository for all of these adapters (although I use Hypercorn to run the Starlette app), and they work just fine. You could write an app using Pyrseia now!
Pyrseia is still missing a few crucial pieces, though. We don't have control over the wire format, so interoperability is still tricky. Until we deal with this in a flexible way, Pyrseia will only work with itself. We have loftier goals than that, though. We should also consider how to transfer errors/exceptions over the wire.
Stay tuned!