Serving Data

Goals

What a Web Server Does

What is a web server and how is it different from opening an HTML file in a browser?

What is Litestar and how do I add it to the project?

A First Server

Write a Litestar server with a single route at / that returns the message "Hello from the Sasquatch Observatory!".

i
from litestar import Litestar, get


@get("/")
async def index() -> str:
    return "Hello from the Sasquatch Observatory!"


app = Litestar([index])
i
litestar run --app server_hello:app

Explain what happens step by step when the browser visits http://127.0.0.1:8000.

Why index?

The name index comes from the early web, when servers mapped URLs directly to files on disk. index.html was like the index of a book. Modern frameworks keep the convention: the function that handles requests for / is called index because it serves the site's front page.

A Table of Sightings

Create a file called dataset.py containing twenty rows of synthetic sasquatch sighting data. Each row must be a dict with keys for ID, species, sex, weight, color, date/time, latitude, and longitude. Some sex and weight values should be None to represent observations where those details were not recorded.

i
SIGHTINGS = [
    {
        "id": 1,
        "species": "G. canadensis",
        "sex": "Female",
        "weight": 142,
        "color": "dark brown",
        "datetime": "2024-01-08 07:14",
        "latitude": 49.23,
        "longitude": -121.45,
    },

Write a Litestar route at / that imports SIGHTINGS from dataset.py and returns an HTML page with a table of all sightings

Modify the Litestar route to Link to an external CSS file called style.css. Also add a route at /style.css to read and return that file.

i
from pathlib import Path

from htpy import body, head, html, link, table, td, th, title, tr
from litestar import Litestar, MediaType, get

from dataset import SIGHTINGS
from utils import HEADERS, KEYS, fmt

LESSON_DIR = Path(__file__).parent


@get("/", media_type=MediaType.HTML)
async def index() -> str:
    return str(
        html(lang="en")[
            head[
                title["Sasquatch Sightings"],
                link(rel="stylesheet", href="/style.css"),
            ],
            body[
                table[
                    tr[[th[col] for col in HEADERS]],
                    [tr[[td[fmt(s[k])] for k in KEYS]] for s in SIGHTINGS],
                ],
            ],
        ]
    )


@get("/style.css", media_type="text/css")
async def styles() -> str:
    return (LESSON_DIR / "style.css").read_text()


app = Litestar([index, styles])

Explain what MediaType.HTML does and what the browser would show if it was left out.

Write a simple CSS stylesheet that sets the font and page width, puts borders on the table cells, and makes the header row slightly gray.

i
body {
    font-family: sans-serif;
    max-width: 960px;
    margin: 2rem auto;
    padding: 0 1rem;
}

table {
    border-collapse: collapse;
    width: 100%;
}

th,
td {
    border: 1px solid #cccccc;
    padding: 0.4rem 0.8rem;
    text-align: left;
}

th {
    background-color: #f0f0f0;
}

td.label {
    font-weight: bold;
    width: 30%;
}

Detail Pages

Make each sighting ID in the table a link to /sighting/{id}. Add a route that looks up that ID and displays the sighting's details in a two-column table with the field names on the left and values on the right.

i
@get("/", media_type=MediaType.HTML)
async def index() -> str:
    return str(
        html(lang="en")[
            head[
                title["Sasquatch Sightings"],
                link(rel="stylesheet", href="/style.css"),
            ],
            body[
                table[
                    tr[[th[col] for col in HEADERS]],
                    [
                        tr[
                            td[a(href=f"/sighting/{s['id']}")[str(s["id"])]],
                            [td[fmt(s[k])] for k in KEYS[1:]],
                        ]
                        for s in SIGHTINGS
                    ],
                ],
            ],
        ]
    )
i
@get("/sighting/{sighting_id:int}", media_type=MediaType.HTML)
async def detail(sighting_id: int) -> str:
    for s in SIGHTINGS:
        if s["id"] == sighting_id:
            return str(
                html(lang="en")[
                    head[
                        title[f"Sighting {sighting_id}"],
                        link(rel="stylesheet", href="/style.css"),
                    ],
                    body[
                        table[
                            [
                                tr[
                                    td(class_="label")[label],
                                    td[fmt(s[key])],
                                ]
                                for key, label in LABELS.items()
                            ],
                        ],
                        a(href="/")["Back to all sightings"],
                    ],
                ]
            )
    raise NotFoundException(f"No sighting with ID {sighting_id}")


@get("/style.css", media_type="text/css")
async def styles() -> str:
    return (LESSON_DIR / "style.css").read_text()


app = Litestar([index, detail, styles])
i
litestar run --app server_detail:app

Trace what happens when a user visits /sighting/99 and ID 99 is not in the data.

Check Understanding

See [litestar2025] for the full Litestar documentation and [mdn-html2024] for the HTTP reference.

What is the difference between @get("/") and @get("/sightings")?

Both decorate a function as a GET request handler, but they respond to different URLs. @get("/") handles requests to http://127.0.0.1:8000/ (the root), while @get("/sightings") handles requests to http://127.0.0.1:8000/sightings. A Litestar app can have as many routes as you like, each at a different path.

You visit http://127.0.0.1:8000, but the browser shows "This site can't be reached". What is the most likely cause?

The server is not running: either you have not run litestar run --app server_hello:app, or it crashed on startup, or you stopped it. Check the terminal where you launched the server for error messages.

The code below is supposed to show an HTML page, but the browser displays the raw tags as text. What is wrong and how do you fix it?

The route is missing media_type=MediaType.HTML, so Litestar sends the response as text/plain. The browser therefore treats the content as literal text rather than markup. The fix is:

@get("/", media_type=MediaType.HTML)
async def index() -> str:
    return str(html[body[p["Hello"]]])
@get("/")
async def index() -> str:
    return str(html[body[p["Hello"]]])
Why does the browser make two requests when it loads the sightings table page?

The first request is for the page itself (GET /). The HTML the server returns contains <link rel="stylesheet" href="/style.css">. When the browser parses that tag, it automatically makes a second request (GET /style.css) to fetch the stylesheet. This is why server_table.py needs both an index route and a styles route.

The route below is supposed to display a sighting's details, but visiting /sighting/abc crashes the server with an unhandled exception. Why, and how does adding :int to the path parameter fix it?

Without :int, Litestar captures the URL segment as a plain string and passes it to the handler. When the handler tries to compare it to the integer IDs in SIGHTINGS the logic may fail, and any code that treats it as a number will raise a ValueError or TypeError. Writing {sighting_id:int} tells Litestar to convert the segment to an integer before calling the handler. If the segment is not a valid integer, such as abc, Litestar itself returns a 400 Bad Request response before your function is ever called.

@get("/sighting/{sighting_id}")
async def detail(sighting_id: str) -> str:
    ...

Exercises

Add a Heading to Every Page

Add an <h1> heading to the <body> of both the index page and the detail page in server_detail.py. The index page heading should say "Sasquatch Sightings in British Columbia"; the detail page heading should include the sighting ID, for example "Sighting 7".

Link Back from the Detail Page

The detail page already has a "Back to all sightings" link in server_detail.py. Open server_detail.py, find where that link is built, and move it above the table so it appears at the top of the page rather than below it.

Filter by Species

Add a new route at /species/{name} that returns only the sightings whose species field matches the given name. Display them in the same table format as the index page. What happens if you visit /species/G.%20canadensis? (The %20 is how a space appears in a URL.)

Show a Count

Add a line below the table on the index page that displays the total number of sightings, for example "20 sightings on record". Display it in a <p> element.

Style None Differently

Instead of displaying None values as empty strings, display them as the word "unknown" and add a CSS class unknown to those cells. Add a rule to style.css that makes cells with class unknown appear in gray italic text.