A different way to think about Python API Clients

9 min read Original article ↗

⭐️ A beta release of this tool is now available to download and use


I've spent a lot of my free time in the past few months working on 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 biography This is for everyone 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). 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 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 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 so many 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 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 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 or Starlette directly and rolling their own ORM, routing, and views layer.

“But we have OpenAPI…”

Yes, we do.

OpenAPI 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.

Decorators

I'm going to pull the rug out from under your feet now: I am already working on this idea.

The changes below are available on the main branch of 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

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 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

⭐️ A beta release of this tool is now available to download and use


Thanks for reading this article, if you'd like to get in touch you can reach me through:

Paul Hallett