Creating Tools to Work with APIs

Once you start calling APIs, you'll quickly want to build APIs — of your own or as wrappers around other people's. Python makes both directions cheap. For serving, flask and fastapi turn a function into an HTTP endpoint in a few lines. For consuming, a small client class (as in the previous lesson) keeps the API surface clean for the rest of your code. Good API tooling is the connective tissue between services.

FastAPI is the modern default for new APIs. It generates OpenAPI schemas and interactive docs from your type hints, handles validation automatically, and returns clear error messages on bad input. A typical endpoint is three lines: @app.get("/users/{id}"); def get_user(id: int) -> User: return load(id).

When shipping API clients (SDKs), focus on ergonomics. Hide HTTP details behind named methods with typed parameters. Raise domain exceptions, not HTTPError. Return dataclasses or pydantic models, not raw dicts. Provide async variants only when callers will actually benefit — a sync client is simpler and covers most cases.

Testing API code demands isolation. Use responses or httpx's MockTransport to stub HTTP calls during unit tests. Spin up the service itself only in integration tests. For your own APIs, FastAPI ships TestClient which talks directly to the ASGI app, no network involved.

Building APIs with FastAPI

A FastAPI app is an object: app = FastAPI(). Decorate a function with @app.get("/path") and the return value becomes JSON. Path parameters are typed (id: int); query parameters are type-annotated function arguments with defaults.

Request bodies use pydantic models. class UserIn(BaseModel): name: str; age: int — pass it as a parameter and FastAPI handles validation and parsing.

Shipping client SDKs

An SDK's job is to hide network details and expose a domain vocabulary. One class per major concept (UsersAPI, OrdersAPI) under one root client (client.users, client.orders).

Document with docstrings. Publish types. Version the SDK independently of the service; a breaking SDK change needs a major-version bump even if the wire format is unchanged.

API-building tools.

ToolPurpose
FastAPI
framework
Modern Python web framework.
Flask
framework
Minimal web framework.
Starlette
framework
ASGI core under FastAPI.
pydantic
library
Data validation and schemas.
OpenAPI
spec
Standard API description format.
responses
library
Mock HTTP for tests.
TestClient
class
Test FastAPI apps in-process.
httpx MockTransport
class
Stub HTTP transport for tests.

Creating Tools to Work with APIs code example

The script simulates a tiny API server + client in pure Python — no web framework required — to illustrate the shape of both sides.

# Lesson: Creating Tools to Work with APIs
from dataclasses import asdict, dataclass, field


@dataclass
class User:
    id: int
    name: str
    age: int


class UsersStore:
    """In-memory 'database' for the demo."""
    def __init__(self):
        self._data: dict[int, User] = {}
        self._next_id = 1

    def create(self, name: str, age: int) -> User:
        u = User(id=self._next_id, name=name, age=age)
        self._data[u.id] = u
        self._next_id += 1
        return u

    def get(self, uid: int) -> User:
        if uid not in self._data:
            raise KeyError(uid)
        return self._data[uid]


# --- "server" side: pure function; FastAPI would wrap this as @app.get/... ---
class API:
    def __init__(self):
        self.users = UsersStore()

    def handle(self, method: str, path: str, body: dict | None = None):
        if method == "POST" and path == "/users":
            u = self.users.create(**body)
            return 201, asdict(u)
        if method == "GET" and path.startswith("/users/"):
            uid = int(path.rsplit("/", 1)[-1])
            try:
                return 200, asdict(self.users.get(uid))
            except KeyError:
                return 404, {"error": "not found"}
        return 405, {"error": "method not allowed"}


# --- "client" side: simulates requests.Session over the in-process API --
class APIClient:
    class NotFound(Exception): ...

    def __init__(self, server: API):
        self._server = server

    def create_user(self, name: str, age: int) -> User:
        code, body = self._server.handle("POST", "/users", {"name": name, "age": age})
        assert code == 201
        return User(**body)

    def get_user(self, uid: int) -> User:
        code, body = self._server.handle("GET", f"/users/{uid}")
        if code == 404:
            raise APIClient.NotFound(body["error"])
        assert code == 200
        return User(**body)


api = API()
client = APIClient(api)

u1 = client.create_user("ana", 30)
u2 = client.create_user("ben", 25)
print("created:", u1, u2)

print("fetched:", client.get_user(u1.id))

try:
    client.get_user(999)
except APIClient.NotFound as err:
    print("handled 404:", err)

Key ideas:

1) Server logic lives in pure functions/methods; the HTTP layer is just dispatch.
2) Client returns dataclasses, never raw dicts, so callers get typed values.
3) Domain exceptions (`APIClient.NotFound`) replace HTTP status codes at the boundary.
4) In real life you'd swap `API` for FastAPI and `APIClient` for a requests-based wrapper.

Sketch a FastAPI endpoint you would write to expose the same store.

# pip install fastapi uvicorn
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()
store = {}
_next_id = 1

class UserIn(BaseModel):
    name: str
    age: int

@app.post("/users")
def create_user(u: UserIn):
    global _next_id
    uid = _next_id
    _next_id += 1
    store[uid] = u
    return {"id": uid, **u.dict()}

@app.get("/users/{uid}")
def get_user(uid: int):
    if uid not in store:
        raise HTTPException(404, "not found")
    return {"id": uid, **store[uid].dict()}

Client contract test.

# Uses the classes defined above
api = API()
c = APIClient(api)
u = c.create_user("x", 1)
assert c.get_user(u.id) == u
try:
    c.get_user(9999)
except APIClient.NotFound:
    pass
else:
    raise AssertionError("expected NotFound")

Running prints:

created: User(id=1, name='ana', age=30) User(id=2, name='ben', age=25)
fetched: User(id=1, name='ana', age=30)
handled 404: not found