By the end you'll know
- What a web framework does, and what an endpoint is
- How to build and run a small API with FastAPI
- How FastAPI writes documentation for you, at
/docs - How to answer with a proper error, like 404, using
raise - Enough about classes to describe your data
- How to test an API with pytest
Where your checker is
At the end of Lesson 9, your checker is a complete tool: it runs by itself, keeps a log, and alerts your phone when something's wrong. Its results land in results.json. But only you can read that file, by opening it.
In Lesson 6, your checker asked GitHub's API for data, and got JSON back. This lesson turns that around: you'll build a small API of your own, so any program, a browser, a dashboard or a colleague's script, can ask your checker for its latest results. It's the last piece of the tool you started in Lesson 1.
What a web framework does
A restaurant decides what's on the menu and how each dish is made; the staff take the orders, bring out the dishes, and tell customers politely when they ask for something that isn't served. A web framework is the staff. You write the menu and the recipes, and it handles everything else: listening for requests, sending back responses, and turning your answers into JSON.
Each item on your API's menu is an endpoint: an address it answers, like /results, and the function that runs when someone asks for it. FastAPI is a popular Python framework for building APIs, and it's what you'll use.
Install FastAPI
With your virtual environment active, in your site-checker folder:
pip install "fastapi[standard]"
The [standard] part asks for FastAPI with its usual extras, including the server that runs it and the fastapi command. The quotes matter: some shells, including the default one on a Mac, treat square brackets as a pattern for matching file names, and the quotes tell the shell to leave them alone.
Your first endpoint
Step 1. Create a new file called api.py in your site-checker folder, next to checker.py. It's a separate program: the checker writes the results, and the API serves them. Start with one endpoint:
# A small API for the website checker's results.
from fastapi import FastAPI
app = FastAPI(title="Site checker API")
@app.get("/health")
def health():
return {"status": "ok"}
app = FastAPI(...)creates your API. Everything else hangs offapp.@app.get("/health")is a decorator: a line starting with@, just above a function, that adds something to it. This one tells FastAPI "when someone asks for/health, run the function below". Thegetmeans it answers requests that ask for something, the same kindrequests.get()sends.- The function returns a dictionary, and FastAPI turns it into JSON for you.
A /health endpoint just answers "I'm running". It looks too simple to bother with, but it's the first thing monitoring tools check, including the kind of checker you've just built. Start the server:
fastapi dev api.py
⚡️ Starting FastAPI in development mode
🐍 Using import string: api:app
🌐 Server started at http://127.0.0.1:8000
Documentation at http://127.0.0.1:8000/docs
followed by a few lines of logs. The server keeps running, so this terminal is now busy; open a second one for anything else. Now open your browser at http://127.0.0.1:8000/health, and you'll see:
{"status":"ok"}
That's your API answering. 127.0.0.1 is the address every computer uses for itself, so only your own computer can reach it. 8000 is the port: if your computer is an apartment building, a port is a flat number, so several programs can each have their own door.
Now open http://127.0.0.1:8000/docs. FastAPI has written a documentation page for your API, listing every endpoint, and you can click Try it out to call one right there in the browser. You didn't write a word of it.
Describing your data with a class
Every result in results.json has the same shape: a name and a result, both text. A paper form has the same boxes on every copy, and a class is that template in Python: it describes a kind of thing, and every result you make from it has the same fields. Lesson 2 said classes are how you make types of your own; this is one.
FastAPI uses a library called Pydantic for this. You describe the fields, and Pydantic checks that the data really has that shape.
Step 2. In api.py, change the imports at the top to these:
import json
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
Then add this class and function below the app = FastAPI(...) line:
class SiteResult(BaseModel):
name: str
result: str
def load_results():
try:
with open("results.json") as file:
return json.load(file)
except FileNotFoundError:
raise HTTPException(status_code=503, detail="No results yet. Run the checker first.")
class SiteResult(BaseModel):makes a new type calledSiteResult, built on Pydantic'sBaseModel. Class names use capital letters for each word, which is how you can tell them apart from functions.name: stris a type hint: it says "name must be a str". That's what Pydantic checks.json.load()reads JSON from a file and turns it into Python, the reverse ofjson.dump()in Lesson 5.- If
results.jsondoesn't exist yet,open()raises a FileNotFoundError. Instead of crashing, the function raises an HTTPException, which FastAPI turns into a proper response: status code 503, "service unavailable", with a message saying why.raiseis how you raise an exception yourself, rather than waiting for Python to.
Now add the endpoint, below health():
@app.get("/results")
def all_results() -> list[SiteResult]:
return load_results()
The -> list[SiteResult] after the brackets is a type hint for what the function hands back: a list of SiteResults. FastAPI uses it to check the data before sending it, and to describe the response on the docs page.
You don't need to restart anything: fastapi dev watches your files and reloads when you save. Open http://127.0.0.1:8000/results:
[{"name":"github.com","result":"up"},{"name":"pypi.org","result":"up"},{"name":"missing page","result":"down"},{"name":"no such site","result":"error"}]
That's results.json, from your checker's last run, served to anyone who asks. Your own results may differ, depending on the last run.
One result, or a polite no
Step 3. Add one more endpoint, at the end of api.py:
@app.get("/results/{name}")
def one_result(name: str) -> SiteResult:
for site in load_results():
if site["name"] == name:
return site
raise HTTPException(status_code=404, detail="Site not found")
The {name} in the address is a path parameter: whatever's in that part of the address is passed to the function as name. Ask for /results/github.com, and name is "github.com". The loop looks for a matching site and returns it. If the loop finishes without finding one, the function raises a 404, the same "not found" code GitHub sent your checker in Lesson 6.
A good waiter tells you "sorry, that's not on the menu"; they don't bring you an empty plate. The raise line is that answer. Try both:
- http://127.0.0.1:8000/results/github.com gives
{"name":"github.com","result":"up"} - http://127.0.0.1:8000/results/nope.example gives
{"detail":"Site not found"}, with status 404
Checkpoint: your API so far
Your api.py should now look like this:
# A small API for the website checker's results.
import json
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="Site checker API")
class SiteResult(BaseModel):
name: str
result: str
def load_results():
try:
with open("results.json") as file:
return json.load(file)
except FileNotFoundError:
raise HTTPException(status_code=503, detail="No results yet. Run the checker first.")
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/results")
def all_results() -> list[SiteResult]:
return load_results()
@app.get("/results/{name}")
def one_result(name: str) -> SiteResult:
for site in load_results():
if site["name"] == name:
return site
raise HTTPException(status_code=404, detail="Site not found")
Open http://127.0.0.1:8000/docs again: all three endpoints are listed, and SiteResult appears under Schemas, described from your class. Try each one with Try it out.
The two programs now work as a pair. cron runs checker.py every 15 minutes, which writes results.json; api.py reads it whenever someone asks. Because load_results() reads the file on every request, the API always serves the latest run, with no restart.
In my real setup
CivicGrid's API is built exactly this way, with FastAPI. Where your API has /results, mine has /cities; where yours has /results/{name} and a 404 saying "Site not found", mine has /cities/{city_id} and a 404 saying "city not found". It has a /health endpoint like yours, and a public documentation page that FastAPI writes for it, just as it did for you: you can open it at api.civicgrid.org/docs (opens in a new tab). The pieces around it, like a database and API keys, are bigger, but the shape is the one you've just built.
Testing the API
Lesson 7's tests checked the checker's rules without visiting a website. FastAPI comes with a TestClient that does the same for your API: it sends requests straight to app, without starting a server.
Step 4. Create test_api.py next to api.py:
from fastapi.testclient import TestClient
from api import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_unknown_site_is_404():
response = client.get("/results/no-such-site.example")
assert response.status_code == 404
client.get() works like requests.get(), and the response has the same .status_code and .json(). In a second terminal, with the virtual environment active, run pytest:
test_api.py .. [ 28%]
test_checker.py ..... [100%]
============================== 7 passed in 0.12s ===============================
pytest found both test files: the checker's 5 tests and the API's 2. (The 404 test needs results.json to exist, so run the checker at least once first.)
Break it on purpose
Try each one, then put it back.
No results yet. Rename results.json to results.bak, and open http://127.0.0.1:8000/results:
{"detail":"No results yet. Run the checker first."}
with status 503. open() raised a FileNotFoundError, load_results() caught it, and turned it into an answer that tells the caller what's wrong and what to do. Without that except, they'd get an unhelpful "Internal Server Error". Rename the file back to results.json.
Forget to raise. In one_result(), delete the word raise from the last line, leaving just HTTPException(status_code=404, detail="Site not found"). Save, and open http://127.0.0.1:8000/results/nope.example:
Internal Server Error
with status 500, and in the terminal running the server, a long traceback that ends:
fastapi.exceptions.ResponseValidationError: 1 validation error:
{'type': 'model_attributes_type', 'loc': ('response',), 'msg': 'Input should be a valid dictionary or object to extract fields from', 'input': None}
Without raise, the line makes an HTTPException and throws it away, the function reaches its end, and hands back None, the same thing a missing return did in Lesson 4. FastAPI then checked None against your type hint, SiteResult, and refused to send it. A 500 means "the server broke", which is the wrong message: nothing's broken, the site just isn't there. Put raise back, and the 404 returns.
Start a second server. Leave the first one running, and in another terminal, in the same folder, run fastapi dev api.py again. Among the output:
ERROR: [Errno 48] Address already in use
(On Linux the number is 98.) Only one program can use a port at a time, and the first server already has 8000: two programs can't share a flat. Stop the second one with Ctrl+C. To run two APIs side by side, you'd give the second its own port, with fastapi dev api.py --port 8001.
When you're done, stop the server in the first terminal with Ctrl+C too.
Where you are now
That's the whole track. Look at what your checker started as, in Lesson 1: two print() lines. It now reads its sites from a file, checks each one over the internet with retries and timeouts, saves results and keeps a log, runs every 15 minutes by itself, alerts your phone when something's wrong, is covered by tests, and serves its results through an API with its own documentation.
Every one of those is a real skill, used in real infrastructure every day. Your API has one gap worth knowing about: it has no lock. On 127.0.0.1 only your own computer can reach it, but put it on a server and anyone could. Securing an API, with keys, limits and the rest, is a track of its own, and it starts from exactly where this one ends.
Check yourself
What is an endpoint?
Why does pip install "fastapi[standard]" need quotes?
Someone asks for /results/nope.example, which isn't in results.json. What do they get?
What does name: str inside the SiteResult class say?
You start fastapi dev a second time. Why do you get Address already in use?
Why raise HTTPException(status_code=404) instead of just letting the function end?
Key terms
- Web framework: code that handles requests and responses, so you only write the endpoints. FastAPI is one.
- Endpoint: an address an API answers, like
/results, and the function behind it. - Decorator: a line starting with
@above a function that adds something to it, like@app.get("/health"). - Port: a numbered door on a computer that one server listens on, like
8000. - 127.0.0.1: the address every computer uses for itself. Only your own computer can reach it.
- Class: a template for a kind of thing, with the same fields every time, like
SiteResult. - Type hint: a note on a variable or function about what type it holds or returns, like
name: str. - Path parameter: part of an address that's passed to the function, like
{name}in/results/{name}. - raise: creates an exception yourself, like
raise HTTPException(status_code=404). - TestClient: sends requests straight to a FastAPI app in tests, without starting a server.
Go deeper
- The FastAPI tutorial (opens in a new tab) goes from first steps to databases and security, one page at a time.
- Classes (opens in a new tab) in the official Python tutorial, for when you're ready to write your own beyond Pydantic models.