Python basics · Lesson 10 of 10 · about 30 minutes

Build your own API with FastAPI

How to turn the checker's results into a small API of your own with FastAPI: endpoints, automatic documentation, proper error responses, a first look at classes, and tests.

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:

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

PYTHON
# 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 off app.
  • @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". The get means it answers requests that ask for something, the same kind requests.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:

BASH
fastapi dev api.py
TEXT
 ⚡️ 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:

JSON
{"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:

PYTHON
import json

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

Then add this class and function below the app = FastAPI(...) line:

PYTHON
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 called SiteResult, built on Pydantic's BaseModel. Class names use capital letters for each word, which is how you can tell them apart from functions.
  • name: str is 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 of json.dump() in Lesson 5.
  • If results.json doesn'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. raise is how you raise an exception yourself, rather than waiting for Python to.

Now add the endpoint, below health():

PYTHON
@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:

JSON
[{"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:

PYTHON
@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:

PYTHON
# 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.

The checker and its APIOn the left, cron runs checker.py every 15 minutes, and the checker writes results.json. In the middle, api.py reads results.json each time a request arrives. On the right, a browser or another program asks for /health, /results or /results/{name}, and gets JSON back, or a 404 if the site isn't found.checker.pyrun by cronwritesresults.jsonreadsapi.py/health/results/results/{name}requestJSON or 404Browser oranother programThe checker writes the results. The API answers anyone who asks for them.
Two programs, one file between them. The checker updates results.json on a schedule; the API reads it on every request, so callers always get the latest run.

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:

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

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

JSON
{"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:

TEXT
Internal Server Error

with status 500, and in the terminal running the server, a long traceback that ends:

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

TEXT
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

  1. What is an endpoint?

  2. Why does pip install "fastapi[standard]" need quotes?

  3. Someone asks for /results/nope.example, which isn't in results.json. What do they get?

  4. What does name: str inside the SiteResult class say?

  5. You start fastapi dev a second time. Why do you get Address already in use?

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