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.
Challenge: the quick links bar
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.