I've spent a lot of my free time in the past few months working on [Clientele](https://github.com/phalt/clientele), which is a project I started way back in 2023. I've always had big ambitions for this project but I have been constrained by a lack of time and not being able to accurately articulate the problem I am trying to solve. I recently read Sir Tim Berners-Lee's recent biography [This is for everyone](https://thisisforeveryone.timbl.com/) and it rekindled an enthusiasm and love for software that I had lost. So I decided to put that enthusiasm into Clientele and making it into the project I've always wanted it to be: accumulating all my insights and knowledge of Python HTTP APIs into a framework and tool that can (hopefully) massively reduce the time it takes to confidently integrate with an API service. ## The Missing Half of the Python HTTP API Story I've spent years _talking_ about HTTP APIs in python (You can see a playlist of my talks [here](https://www.youtube.com/playlist?list=PLg_YNMBmzvRu_dnZsD07FV9ZG_ex9afht)). I've also _worked_ in tech start ups for the past decade in London, San Francisco and New Zealand. I've also _built_ some [very popular API services](https://pokeapi.co) and tested and used at least one hundred others. The one frustration I keep seeing again and again with APIs is the client integration story. I often refer to it as the "last mile problem" - building and creating the API service is often very easy in Python. But the integration on the client side is always slow and difficult. Even when you own both services (in a micro-service like architecture) it feels like 80% of the effort is on the client to properly connect, use, and maintain a reliable integration. Why is this? And what do I think we need to do about it? ## Python HTTP API server code feels declarative and mature Here is a [FastAPI](https://fastapi.tiangolo.com/) code snippet that provides a complete HTTP API server with declarative inputs and outputs: ```Python from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class User(BaseModel):     id: int     name: str @app.get("/users/{user_id}", response_model=User) def get_user(user_id: int) -> User:     return User(id=user_id, name="Alice") ``` FastAPI isn't the only mature HTTP API service in Python - we [have](https://django-ninja.dev/) so [many](https://www.django-rest-framework.org/) options available to us. This is brilliant: building HTTP APIs in Python is really, really mature. What's so good about FastAPI's in particular? **You can understand the code** Once you grok FastAPI's functionality it is easy to read the code and understand how this translates to the HTTP endpoint and what it offers. **Types are explicit** Python typing is being heavily adopted, and many frameworks are leaning into typing to do all sorts of cool tricks to enable a bug-free, declarative developer experience. **Behaviour is obvious, even if it is abstracted** Despite offering a high-level API over [starlette](https://starlette.dev/) the FastAPI interfaces still provide insight into what the framework is doing. The abstraction is _just right_ and allows the developer to focus on their project-specific needs. **Lots of stuff for free** FastAPI and other HTTP API frameworks bundle in loads of common configuration and features that they know developers will need, including: - OpenAPI schema generation - API documentation (often derived from the OpenAPI docs) - Authentication - CORS - Hydration from request bodies to typed objects ## The HTTP API Client problem When you compare this to the other side of the integration - the client - the developer experience is very different. The “equivalent” code for an API client is usually just the developer's or project's preferred HTTP client making requests. This example shows an [httpx](https://www.python-httpx.org/) request to the same API server we just wrote: ```python import httpx def get_user(user_id: int) -> dict:     response = httpx.get(f"https://api.example.com/users/{user_id}")     response.raise_for_status()     return response.json() ``` The project or developer may put in more effort and wrap the HTTP response in a typed model instead of returning the JSON dictionary. Or they may maintain a list of endpoints instead of hard coding strings. Every developer and project has it's own way of dealing with integration. The same level of abstraction for HTTP API clients just doesn't exist like it does for HTTP API servers. It would be the equivalent of everyone today building API servers with [asgi](https://github.com/django/asgiref) or Starlette directly and rolling their own ORM, routing, and views layer. ## “But we have OpenAPI…” Yes, we do. [OpenAPI](https://www.openapis.org/) provides an abstract representation of an API service that a client could in theory integrate comfortably with. OpenAPI's adoption on the server side is mature as it can be - all the major Python HTTP API frameworks produce compliant OpenAPI schemas for free. On the client side, nearly everything we have available for turning OpenAPI schemas into client integration is just code generators. These generated clients often generate obtuse and confusing code like this: ```python client.users_api.get_user_with_http_info(user_id=123) ``` It's only been in the past few years that client code generators have caught up with modern Python and offered things like strong typing, pydantic models etc. But they all still just provide boilerplate code and the raw components. There is no comfortable abstraction. My personal experience with OpenAPI code generators is so poor that I never bother. I've tried plenty and always get the same - the generated code still takes a long time to understand and integrate because of the size of it, the "not quite how we write python"-ness for the client project, the fact that it produces a full stack of code down to the raw http transport which the developer then has to maintain. In fact, this experience was why I originally started Clientele - I was so frustrated that client generators were so poor I took my "ideal" client and worked backwards to make a code generator that produced the sort of code I liked. A big part of Clientele's generated code is abstracting away a lot of boiler plate into one place and maintaining a few modules that were considered clean and the only modules a developer needed to use. Clientele is focussed on the developer experience first. ## Inspiration from API server frameworks So to refocus: we have very mature and amazing HTTP API server frameworks. These frameworks, particularly FastAPI, have taught us that: - Functions are a popular unit for encapsulating endpoint behaviour - Decorators are popular for declaring the configuration for those endpoints - Types are amazing for documentation and validation So the obvious question is: > Can we make API clients that act like this too? ## Clientele is a different way to think about Python API clients Instead of writing code like this: ```python httpx.get("https://api.example.com/users/123") ``` Can we instead write code like this: ```python get_user(123) ``` So that we can have a framework where the function declares intent, and the framework handles transport, hydration, validation etc. I think we might be able to do this elegantly with [decorators](https://wiki.python.org/moin/PythonDecorators#What_is_a_Decorator). ## Decorators I'm going to pull the rug out from under your feet now: I am already working on this idea. I have a [branch](https://github.com/phalt/clientele/tree/framework) on Clientele where I am building this exact framework and I've been able to get it working pretty reliably. You can see a working example client [here](https://github.com/phalt/clientele/blob/framework/server_examples/fastapi/client/client.py) Here is an example of how a modern Python API client could look: ```python import clientele from server_examples.fastapi.client import config from server_examples.fastapi.client.schemas import CreateUserRequest, HTTPValidationError, UserResponse # declare a client instance client = clientele.Client(config=config.Config()) # bind functions to endpoints @client.get("/users/{user_id}") def get_user(user_id: int, result: UserResponse) -> UserResponse: return result CreateUserUnion = HTTPValidationError | UserResponse # An HTTP POST example @client.post("/users", response_map={200: UserResponse, 422: HTTPValidationError}) def create_user(data: CreateUserRequest, result: CreateUserUnion) -> CreateUserUnion: return result ``` This should feel instantly familiar to anyone using an HTTP API framework like FastAPI, Django-Ninja or even Flask, and that is intentional because it is a model that Python developers are familiar with, and it works. The key idea is that functions are used declaratively to support explicit typing and IDE support. The signature of the function is the contract, even though the function doesn't actually do work except provide clearly typed inputs and outputs. The key focus with this framework is that the decorator manages everything else needed for an HTTP API client: - URL construction - Path params - Query params - Serialization - HTTP execution - Response parsing - Error mapping While the function owns: - Name - Inputs - Outputs - Documentation - IDE experience **Configuration** In the same way that today's Clientele generates a `Config` object that provides all the configuration a developer might need for the http layer, the Clientele framework I am building will work in the same way. Again, this is intentionally similar to how FastAPI offers a bunch of configuration options for the server if you want it. **Working with OpenAPI** With this framework we create clients from scratch, but it's real potential opens up when we use OpenAPI to provide scaffolding. I am updating Clientele's generator to provide the framework-style version when provided with an OpenAPI Schema. This would mean that we could have an OpenAPI client generator that didn't just produce a load of low-level boilerplate, but instead a high-level abstraction with pre-generated operations and schemas for us to use - all neatly typed and declarative. ## Why we need better API Client tools I genuinely believe that API client ergonomics matter as much as API server ergonomics, if not more. We need to remember that APIs are products, HTTP is a medium for connectivity. The current effort involved in integration causes a lot of friction, which consumes developer's time from the real hard problems. Right now Python API servers feel very modern, but Python API clients feel like they've stagnated since 2015, the only real innovation has been support for async. ## Closing thoughts I want to stress that this isn't about replacing replacing httpx / requests or hiding HTTP. It’s about choosing the right abstraction level that offers a brilliant developer experience and equal productivity for API creation / consumption. I want clients to feel as intentional as servers, to be treated as first-class Python code. I am going to continue building the Clientele framework and testing it out, because I think there is some real untapped value in this space. ## Your thoughts - Should client code mirror server code like this? - Are decorators the right abstraction? - What would your “FastAPI of clients” look like? ## Test it out If you want to test out the Clientele framework, you can [checkout the framework branch](https://github.com/phalt/clientele/tree/framework) and run it locally. There is even a draft generate command to take an OpenAPI schema and build a client using the framework, but I've not fully tested it yet: ``` clientele generate-framework -u http://path.to/openapi.json ``` Thanks for reading this article, if you'd like to get in touch you can reach me through: - [Bluesky](https://bsky.app/profile/paulwrites.software) - [Email](mailto:[email protected]) - [GitHub](https://github.com/phalt) Paul