Python for Humans · Draft v0.2 · Oct 29, 2026 · Draft position: chapter 21 · Add an optional USGS dashboard with saved data, a JSON endpoint and a browser chart.

Your First API

You should already know
Requests, JSON, functions, imports and the word counter from Modules.
You'll build
An API you can call through interactive documentation or another Python program.
You'll be able to
Declare a request body, validate input and return structured results.
You need
Your local setup, any plain-text editor, uv and internet access for installing packages. Python 3.14 is selected for these examples.

Your weather program called somebody else's API. Now another program can call yours.

We'll wrap count_words() in a FastAPI endpoint. Unlike the Django reading list, its main result is JSON for another program to use.

Set Up the Service

mkdir text-api
cd text-api
uv init --bare --vcs none --no-workspace --python 3.14
uv python pin 3.14
uv add fastapi uvicorn httpx

Copy your existing text_tools.py and test_text_tools.py into this folder. Run uv run test_text_tools.py to check the helper before connecting it to HTTP.

FastAPI supplies routing and request handling. Uvicorn runs the local server. httpx supports the test client we'll use below.

Create main.py:

from fastapi import FastAPI
from pydantic import BaseModel, Field
from text_tools import count_words

app = FastAPI(title='Your Text API')


class TextInput(BaseModel):
    text: str = Field(max_length=20_000)


@app.post('/analyze')
def analyze(submission: TextInput):
    counts = count_words(submission.text)
    return {'counts': counts, 'distinct_words': len(counts)}

Or download the source and save it as main.py.

Read the New Syntax

TextInput inherits validation behavior from Pydantic's BaseModel. Its text: str annotation declares a string field. Field(max_length=20_000) limits the accepted string to twenty thousand characters. Requests with a missing field, an invalid type or an overlong string receive a validation error before our counter runs.

@app.post('/analyze') registers the following function for POST requests at that path. This decorator connects the function to the framework, much as Django's URL configuration did.

FastAPI reads the JSON body, validates it, and supplies a TextInput instance as submission. Our function accesses submission.text, calls the helper and returns an ordinary dictionary. FastAPI turns that result into a JSON response.

The counting rules are still defined in text_tools.py. No network-specific code needs to be added to that function.

Try the Interactive Documentation

uv run uvicorn main:app --reload

main:app names the module and the application object inside it. Leave the server running and open http://127.0.0.1:8000/docs. If another server is using the port, stop it first or add --port 8001 and use that address.

Expand POST /analyze, choose Try it out, and enter:

{"text": "Red red blue."}

Execute the request. The response should be:

{"counts": {"red": 2, "blue": 1}, "distinct_words": 2}

The documentation page is a client of your API. It builds its form from FastAPI's description of the route and request model. Visiting /analyze directly in a browser uses GET, so it won't call this POST endpoint.

Try an empty string, then remove the text field entirely. The first is a valid input producing no words. The second should receive status 422, indicating a validation problem. Read the returned error location before changing the request.

Check It Without a Listening Server

Save this as check_api.py:

from fastapi.testclient import TestClient
from main import app

client = TestClient(app)
response = client.post('/analyze', json={'text': 'Red red blue.'})
assert response.status_code == 200
assert response.json() == {'counts': {'red': 2, 'blue': 1}, 'distinct_words': 2}
assert client.post('/analyze', json={}).status_code == 422
assert client.post('/analyze', json={'text': ''}).json()['counts'] == {}
print('API checks passed.')

Stop Uvicorn with Ctrl-C and run uv run check_api.py. FastAPI's test client calls the application in this process. Its json= argument encodes the dictionary as a JSON request body; .json() decodes the response. We assert Python values rather than depending on the spacing of JSON text.

The service accepts text, not a filename on the server. A request is processed independently; nothing in this version stores the submitted text between requests.

Optional: Turn Public Data into a Dashboard

Your API can also sit between a data source and a visualization. We'll add a second route that summarizes a saved USGS earthquake feed, then a page that draws those results as a bar chart. Keep /analyze; this is a second use for the same service.

The flow is:

USGS CSV → Requests downloader → saved snapshot
saved snapshot → FastAPI /earthquakes → JSON → browser chart and table

This extension connects The Web in One Chapter with the earthquake notebook. The notebook explores data directly in Python. Here, Python supplies data to a separate browser client.

Add the Files

Stop the server before changing the project. Install one more package:

uv add requests

Download these files into your existing text-api folder, keeping the listed names:

Download Save as Job
Snapshot downloader download_earthquakes.py Fetch and validate the USGS CSV, then record its source and retrieval time
Earthquake routes earthquake_api.py Count magnitudes from the saved CSV and serve the dashboard
Browser dashboard dashboard.html Request JSON and display a chart and a table
Dashboard checks check_earthquakes.py Check known counts and the new endpoints

In main.py, add this import beside the other imports:

from earthquake_api import router

Then add this line immediately after app = FastAPI(...):

app.include_router(router)

Or download the complete combined version and save it as main.py. Keep text_tools.py beside it.

An APIRouter groups related routes in another module. Including it registers those routes with the same app. The two new paths are GET /earthquakes for JSON and GET / for the page.

Save a Snapshot

Run the downloader once:

uv run download_earthquakes.py

It uses requests.get() with a timeout, checks the HTTP status and CSV columns, and saves data/earthquakes.csv plus data/earthquakes.csv.source.json. Network or validation failures leave an existing snapshot in place. The metadata records the source URL, retrieval time in UTC and a checksum of the downloaded bytes.

If the feed is unavailable, create a data folder yourself and download our saved complete earthquake snapshot as data/earthquakes.csv and its source metadata as data/earthquakes.csv.source.json. Preserve the original retrieval time: copying a file doesn't make its observations newer.

These files live relative to earthquake_api.py, using Path(__file__).resolve().parent. Starting Python from another working folder won't change which snapshot it reads.

Follow One Request

Start the server again:

uv run uvicorn main:app --reload

Open http://127.0.0.1:8000/earthquakes?minimum=2.5. You'll see JSON containing labels, counts, selected, missing_magnitudes and source. Live downloads vary, so predict relationships rather than a particular day's count: sum(counts) must equal selected.

The route declares its query parameter like this:

@router.get('/earthquakes')
def earthquakes(minimum: float = Query(default=2.5, ge=-2, le=10)):

FastAPI converts the query string to a number and accepts values from −2 through 10. Try minimum=elephant to see a validation error.

Read summarize() in earthquake_api.py. It skips other event types, tracks missing or non-finite magnitudes separately, filters by the chosen minimum, then counts the remaining records in magnitude ranges. Negative magnitudes are possible; a blank magnitude is unknown, so it mustn't become zero. A minimum of 2.5 includes only part of the 2 to <3 range.

The feed covers recorded events from the past day when downloaded. It isn't a complete census of earthquakes worldwide, and it doesn't advance as you leave the page open. Run the downloader again with the server stopped to refresh it. Page visits only read the saved files.

See the Same Numbers as a Chart

Open http://127.0.0.1:8000/. Change the minimum magnitude and choose Update results. Compare a bar with its table entry, then follow See these results as JSON. All three views use the same response.

The supplied HTML includes a small amount of JavaScript. You can use it without studying a second language first. Find these operations in the file:

Python does the filtering and counting; Chart.js draws the bars. The page and JSON share the same server address, so no additional cross-origin configuration is needed.

The chart uses Chart.js 4.5.1, loaded from a public CDN. That download requires internet access. If it fails, the table and JSON still work. The table also gives exact values and a way to read the results without relying on color or a canvas.

Check the Dashboard

Stop the server and run:

uv run check_api.py
uv run check_earthquakes.py

The first command checks that the word counter still works. The second uses invented rows to check boundaries, missing magnitudes, another event type and an empty input. It then checks the actual routes against your saved snapshot, without expecting a fixed count from a changing feed.

Try setting the minimum to 10. An empty chart can be a valid result. Rename data/earthquakes.csv temporarily and request /earthquakes: the API should return status 503 with instructions to restore the snapshot. Put the file back afterward.

For one independent change, add a count of the records excluded by the minimum. Decide whether missing magnitudes belong in that count before writing the code.

Your Turn

Add a total_words result computed with sum(counts.values()). Predict it for the tiny input, then add an assertion. Check that it differs from the number of distinct words.

Add a GET /health endpoint returning {'status': 'ok'}. Explain why it can be a simple GET while analyzing a supplied request body uses POST here.

A larger extension is a daily briefing: another script calls this or public APIs and writes an HTML report. Keep credentials and deployment as separate follow-on work.

What I've Learned

Explain the jobs of FastAPI, Uvicorn and the counting function. Describe the difference between an empty valid string and a missing required field.

The FastAPI tutorial develops request models and routes further. The Chart.js guide explains the chart configuration used in the optional dashboard.