APIs: Build One, Call One
The pass-through window from the last lesson is still there, except now you are standing on the kitchen side. You set the menu. You decide how fast the food goes out. You decide which tickets get rejected before the cook ever sees them. In Python the simplest way to stand up a working kitchen is a framework called FastAPI. You write a handful of Python functions, you decorate each one with the URL it answers, and FastAPI handles the rest — reading the request, validating the body, calling your function, writing the response back.
Armin Ronacher shipped Flask in 2010 as a one-file reaction to the heavy Django web framework. Flask let anyone put a web server on the internet in 5 lines, and a decade of Python web projects grew on it. The trouble was everything Flask did not do: no input validation, no async handling, no auto-generated documentation. In December 2018 a Colombian engineer named Sebastián Ramírez published FastAPI, which bolted 3 pieces together: Starlette (the raw async web layer), pydantic (for validating JSON against Python type hints), and the OpenAPI standard (for turning those hints into a free interactive docs page). It exploded. By 2022 FastAPI was on every serious Python backend at startups across the world, because Ramírez solved the one thing Flask never did: the request shape was part of the function signature, and Python itself enforced it.

The poker project you built still lives on disk. You are about to put it behind a kitchen window so any caller with HTTP can score a hand. Start a fresh project folder for the API alongside the poker one.
cd ~/learning-python
mkdir poker-api
cd poker-api
python3 -m venv .venv
source .venv/bin/activate
pip install fastapi "uvicorn[standard]" httpx
pip install -e ../pokercd $HOME\learning-python
mkdir poker-api
cd poker-api
py -m venv .venv
.venv\Scripts\Activate.ps1
pip install fastapi "uvicorn[standard]" httpx
pip install -e ..\pokerFour packages arrived. FastAPI is the framework. Uvicorn is the actual web server that listens on a port and speaks HTTP — FastAPI is the thing that decides what to do with the request, Uvicorn is the thing that receives it. The [standard] extra pulls in the fast C bits like httptools that make Uvicorn production-ready. Httpx is the HTTP client you will use in a second to call your own API from another Python script. The pip install -e ../poker line points at the poker project from the project-poker lesson and installs it in editable mode so this venv can from poker.score import score_hand.
Write main.py in the poker-api folder.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from poker.cards import Card
from poker.score import score_hand
app = FastAPI(title="Poker Score API")
class CardIn(BaseModel):
rank: str = Field(pattern=r"^(2|3|4|5|6|7|8|9|10|J|Q|K|A)$")
suit: str = Field(pattern=r"^[shdc]$")
class HandIn(BaseModel):
cards: list[CardIn] = Field(min_length=5, max_length=5)
class ScoreOut(BaseModel):
rank: int
tiebreakers: list[int]
label: str
RANK_LABELS = {
1: "high card",
2: "one pair",
3: "two pair",
4: "three of a kind",
5: "straight",
6: "flush",
7: "full house",
8: "four of a kind",
9: "straight flush",
10: "royal flush",
}
@app.get("/hello")
async def hello() -> dict[str, str]:
return {"greeting": "hello from the poker kitchen"}
@app.post("/poker/score", response_model=ScoreOut)
async def score(hand: HandIn) -> ScoreOut:
cards = [Card(rank=c.rank, suit=c.suit) for c in hand.cards]
if len({(c.rank, c.suit) for c in cards}) != 5:
raise HTTPException(status_code=400, detail="duplicate cards in hand")
result = score_hand(cards)
return ScoreOut(
rank=result[0],
tiebreakers=list(result[1:]),
label=RANK_LABELS[result[0]],
)Look at the shape. CardIn and HandIn are pydantic models. Pydantic takes the type hints on those classes and turns them into a runtime validator: if the caller sends a rank of "11" or a suit of "X", the request is rejected with a 422 Unprocessable Entity and a JSON error body before your function runs. The Field(min_length=5, max_length=5) tells pydantic the list has to have exactly 5 cards. The async def on each handler tells Uvicorn the function runs inside the event loop — FastAPI can service thousands of concurrent requests on a single process, because while one request is waiting on I/O the event loop serves others. Both handlers could have been plain def too; FastAPI runs those in a thread pool. The async version scales better when you add I/O like a database call later.
Run it. Uvicorn is the boss.
uvicorn main:app --reload --host 127.0.0.1 --port 8000uvicorn main:app --reload --host 127.0.0.1 --port 8000The --reload flag watches your source files and restarts the server whenever you save. The --host 127.0.0.1 binds to the loopback interface, which is your machine talking to itself. Use 0.0.0.0 instead when you want the server reachable from other machines on the network. The terminal prints a line saying the server is up.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Application startup complete.Open a browser and go to http://127.0.0.1:8000/docs. FastAPI generated an interactive docs page from your pydantic models and handler signatures. Every endpoint is listed. Each one has a "Try it out" button that sends a real request from the browser. Click POST /poker/score, click Try it out, paste this body, and click Execute.
{
"cards": [
{ "rank": "A", "suit": "s" },
{ "rank": "K", "suit": "s" },
{ "rank": "Q", "suit": "s" },
{ "rank": "J", "suit": "s" },
{ "rank": "10", "suit": "s" }
]
}The response comes back a moment later.
{
"rank": 10,
"tiebreakers": [],
"label": "royal flush"
}The docs page is free. You did not write a single line of HTML. FastAPI read your type hints, wrote the OpenAPI spec, and served Swagger UI on /docs. That is the "one obvious way" philosophy paying off — the same type hints that validate the request also describe the API to the world.

Now call the same API from another Python script. Leave the server running in one terminal and open a second terminal in the same poker-api folder with the venv still active. Write call_api.py.
import httpx
def main() -> None:
royal = {
"cards": [
{"rank": "A", "suit": "s"},
{"rank": "K", "suit": "s"},
{"rank": "Q", "suit": "s"},
{"rank": "J", "suit": "s"},
{"rank": "10", "suit": "s"},
]
}
two_pair = {
"cards": [
{"rank": "K", "suit": "s"},
{"rank": "K", "suit": "d"},
{"rank": "7", "suit": "h"},
{"rank": "7", "suit": "s"},
{"rank": "J", "suit": "h"},
]
}
with httpx.Client(base_url="http://127.0.0.1:8000") as client:
hello = client.get("/hello")
print("GET /hello ->", hello.status_code, hello.json())
for label, hand in [("royal", royal), ("two_pair", two_pair)]:
response = client.post("/poker/score", json=hand)
print(f"POST /poker/score ({label}) ->", response.status_code, response.json())
if __name__ == "__main__":
main()Run it with python call_api.py.
GET /hello -> 200 {'greeting': 'hello from the poker kitchen'}
POST /poker/score (royal) -> 200 {'rank': 10, 'tiebreakers': [], 'label': 'royal flush'}
POST /poker/score (two_pair) -> 200 {'rank': 3, 'tiebreakers': [13, 13, 7, 7, 11], 'label': 'two pair'}The httpx client wraps a TCP connection pool, keeps it warm across calls, and hands you a response object with .status_code, .headers, and .json(). It is the successor to the requests library that your bare urllib.request call replaced. Httpx ships sync and async versions of the same API, so when a handler of yours needs to call another API it can do so without blocking the event loop. That is the whole reason FastAPI pairs with httpx and not with requests.
A question worth answering from the output: what happens when you send only 4 cards? Try it.
bad = {"cards": [{"rank": "K", "suit": "s"}] * 4}
response = client.post("/poker/score", json=bad)
print(response.status_code, response.json())422 {'detail': [{'type': 'too_short', 'loc': ['body', 'cards'], 'msg': 'List should have at least 5 items after validation, not 4', 'input': [...], 'ctx': {'field_type': 'List', 'actual_length': 4, 'min_length': 5}}]}Status 422 is "Unprocessable Entity" — the request was well-formed HTTP, the JSON parsed, but one of the fields failed validation. Pydantic spotted the bad list before your handler ever ran. The detail array tells the caller exactly which field broke and why. That is the blame attribution contract from the philosophy rules, free, one step before you wrote any error-handling code yourself.
Your API returns the same score for the same cards every call. The values live only in the request body — nothing persists. The next time the server restarts, no trace remains of the hands you scored. The next lesson starts the conversation about databases.