Four lessons of foundation, and today it pays off visibly: by the end of this one you can click from the home page to Fish to Angelfish to EST-1, with prices, stock counts, and the same pet pictures the original has been serving since the Bush administration. Checkpoint: lesson-05.

URLs a person can read

The original reaches the Fish page at /actions/Catalog.action?viewCategory=&categoryId=FISH — the ActionBean event model leaking into every address. The port uses paths:

/categories/FISH
/products/FI-SW-01
/items/EST-1
/images/fish1.gif

Axum’s router binds each pattern to a handler, and the {id} segment arrives as a Path<String> extractor argument. Which raises the question lesson 4 already answered: who turns that raw string into a typed id?

The boundary, applied

One helper sits at the top of web/catalog.rs:

fn parse_id<T: TryFrom<String>>(raw: String) -> Result<T, AppError> {
    T::try_from(raw).map_err(|_| AppError::NotFound)
}

Note what the error becomes: not found, not “bad request” with a lecture about id formats. A path segment that can’t even parse is a URL that names nothing, which is the same situation as an id that parses but isn’t in the catalog. Both roads lead to the styled 404. The category handler shows the whole shape:

pub async fn category(
    State(pool): State<SqlitePool>,
    Path(raw): Path<String>,
) -> AppResult<Html<String>> {
    let id: CategoryId = parse_id(raw)?;
    let category = db::catalog::category(&pool, &id).await?.ok_or(AppError::NotFound)?;
    let products = db::catalog::products_in_category(&pool, &id).await?;
    Ok(Html(CategoryTemplate { category, products }.render()?))
}

Six lines: parse, fetch-or-404, fetch, render. Every catalog handler is this handler with different nouns — which is why the lesson only shows this one, and the Build-it table assigns the rest.

Templates meet data

Each page pairs a template struct with a template file. The struct declares what the page needs; the file loops and renders it:

{% for product in products %}
<tr>
    <td><a href="/products/{{ product.id }}">{{ product.id }}</a></td>
    <td>{{ product.name }}</td>
</tr>
{% endfor %}

{% for %}, {% if let Some(x) %}, {% let %} — Askama’s control flow is Rust’s, and all of it type-checks against the struct at build time. Prices render as {{ item.list_price }}, which works because Cents finally grew its Display impl:

impl fmt::Display for Cents {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "${}.{:02}", self.0 / 100, self.0 % 100)
    }
}

Lessons 3 and 4 kept promising that formatting was a display concern; this is the whole payment. One impl, and it’s the only code in the app that knows what a dollar sign is.

The HTML that lives in the database

Now the lesson’s real fight. Pull up EST-1’s product row and look at the description column:

<image src="../images/fish1.gif">Salt Water fish from Australia

Presentation, embedded in data, since 2002. The original’s JSPs print it raw with escapeXml="false" semantics — browser meets whatever the database says, which is a cross-site-scripting incident waiting for a writable field. Askama does the opposite by default: render that string and the visitor sees the literal text <image src=...>, escaped, ugly, and safe.

Neither behavior is what we want. The port’s answer is the boundary move a third time: parse the legacy format once, into structure —

pub struct Description {
    pub image: Option<String>,   // "fish1.gif"
    pub text: String,            // "Salt Water fish from Australia"
}

pub fn parse_legacy_description(descn: &str) -> Description

— a dozen lines of string-walking in domain/catalog.rs (find the <image src="../images/"> prefix, capture the filename, drop any remaining tags, trim). Not an HTML parser; legacy cleanup with unit tests. Templates then render the parts: the image as a real <img> tag the template authored, the text as escaped text. The data stops being trusted markup and becomes what it always was — a filename and a sentence.

Fifteen gifs, zero new crates

The images themselves come from the original (Apache-2.0, like everything else we’re porting) into static/images/, and they ship inside the binary:

"fish1.gif" => gif!("fish1.gif"),   // include_bytes!, plus a content-type

An explicit match over fifteen filenames, each include_bytes!-embedded at compile time. A static-file middleware crate would also work, but fifteen known files don’t justify a dependency, a runtime directory that has to ship next to the executable, or path-traversal thinking. The match is boring, obvious, and keeps the deployment artifact at exactly one file. When lesson 12 measures the binary, the fish are in there.

Build it

File Action What goes in it
src/domain/catalog.rs write Display for Cents and parse_legacy_description + Description, both shown above; a description_parts() method on Product; tests for both (three parse cases, three price formats)
src/web/catalog.rs write parse_id and the category handler are shown; write home, product, and item from the same shape (item’s handler fetches its product too, for the name and image)
src/web/assets.rs write The match-on-filename image handler; the gif! macro wraps include_bytes! + content-type
src/web/mod.rs modify Five routes shown above; mod assets; mod catalog;, drop mod home;
src/web/home.rs delete The counts page served its purpose
templates/home.html write Category list with links — the loop pattern shown above
templates/category.html, product.html, item.html write Same patterns: loops, {% let parts %}, {% if let Some %}, links between pages
static/images/*.gif copy The fifteen gifs from the original’s src/main/webapp/images/

Done when: you can click home → Fish → Angelfish → EST-1 and see “$16.50” and a fish photo; /items/EST-999 and /categories/anything-junk both give the styled 404; cargo test shows 8 passing.

Answer key: lesson-04...lesson-05.

The original’s header has a strip of links straight to each category — FISH, DOGS, REPTILES, CATS, BIRDS — on every page. Add it to base.html. The five categories haven’t changed since 2002 and the original hardcodes its quick links too, so resist the urge to thread a query through every template: five <a> tags in the base layout is the faithful and the simple answer. Notice how it feels to make that call deliberately.

Next lesson: search — one form, one LIKE query, and the ${} versus #{} scar every MyBatis veteran carries.