Routing

FastAPI Rebuild › Chapter 4

Core CMS Routing

The three-segment route that maps URL slugs to database content — plus clean 404 handling

Chapter 4 of 10 APIRouter Path Parameters HTTPException / 404
Chapter Milestone

By the end of this chapter the URL /japan/kanji/kanji-tiles (or any valid subject/subtopic/page combination) will query your database, load the stored HTML content, and render a full page. Invalid slugs at any level return a styled 404 page. Subject and subtopic index pages are also wired up.

1 The complete URL map

Your CMS needs four URL patterns. They share the same slug variables but serve different levels of the hierarchy. The order they are registered matters — FastAPI matches routes top to bottom, so we register them from most specific to most general.

GET / Home — all subjects as cards main.py
GET /{subject_slug} Subject index — list subtopics cms.py
GET /{subject_slug}/{subtopic_slug} Subtopic index — list pages cms.py
GET /{subject_slug}/{subtopic_slug}/{page_slug} Page — render full content from DB cms.py
Chapter 6 preview: in Chapter 6 (Flexible Routing) we will add a route like /japan/kanji/{character} that sits above the generic three-segment route. Because FastAPI matches top-to-bottom, it will be tested first and intercept those URLs before they hit the general CMS pattern. For now, three levels are enough.

2 Creating the CMS router

Rather than adding all routes to main.py, we use FastAPI's APIRouter to group the CMS routes in their own file. This is the same separation-of-concerns pattern as Spring's @Controller classes — each file owns a logical group of routes.

app/routers/cms.py python
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from sqlalchemy import select
from app.database import get_db, get_subjects
from app.models import Subject, Subtopic, Page, PageContent

# APIRouter is to FastAPI what @Controller is to Spring MVC.
# No prefix — routes are registered from the root path.
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")


# ── Shared helper: build the common template context ─────────────────────
# Every CMS page needs subjects (for the nav) and breadcrumb data.
# Centralising it here avoids repeating the same 4 dict keys in each route.
def base_context(request: Request, db: Session, **extra) -> dict:
    return {
        "request":  request,
        "subjects": get_subjects(db),
        **extra,
    }


# ═══════════════════════════════════════════════════════════════════════════
# Route 1 — Subject index:  /japan/
# Lists all subtopics for a subject.
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/{subject_slug}", response_class=HTMLResponse)
async def subject_index(
    subject_slug: str,
    request:      Request,
    db:           Session = Depends(get_db),
):
    subject = db.execute(
        select(Subject).where(Subject.slug == subject_slug)
    ).scalar_one_or_none()

    if not subject:
        raise HTTPException(status_code=404, detail=f"Subject '{subject_slug}' not found")

    return templates.TemplateResponse(
        "subject.html",
        base_context(
            request, db,
            subject=subject,
            active_subject=subject_slug,
        ),
    )


# ═══════════════════════════════════════════════════════════════════════════
# Route 2 — Subtopic index:  /japan/kanji/
# Lists all pages within a subtopic.
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/{subject_slug}/{subtopic_slug}", response_class=HTMLResponse)
async def subtopic_index(
    subject_slug:  str,
    subtopic_slug: str,
    request:       Request,
    db:            Session = Depends(get_db),
):
    subject = db.execute(
        select(Subject).where(Subject.slug == subject_slug)
    ).scalar_one_or_none()
    if not subject:
        raise HTTPException(status_code=404)

    # The subtopic must belong to THIS subject — prevents slug collisions
    # across subjects (two subjects could have a subtopic both named "intro")
    subtopic = db.execute(
        select(Subtopic).where(
            Subtopic.slug       == subtopic_slug,
            Subtopic.subject_id == subject.id,
        )
    ).scalar_one_or_none()
    if not subtopic:
        raise HTTPException(status_code=404)

    return templates.TemplateResponse(
        "subtopic.html",
        base_context(
            request, db,
            subject=subject,
            subtopic=subtopic,
            active_subject=subject_slug,
        ),
    )


# ═══════════════════════════════════════════════════════════════════════════
# Route 3 — Page content:  /japan/kanji/kanji-tiles
# The core CMS route — loads and renders a single page from the database.
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/{subject_slug}/{subtopic_slug}/{page_slug}", response_class=HTMLResponse)
async def cms_page(
    subject_slug:  str,
    subtopic_slug: str,
    page_slug:     str,
    request:       Request,
    db:            Session = Depends(get_db),
):
    # ── Step 1: validate subject slug ─────────────────────────────────
    subject = db.execute(
        select(Subject).where(Subject.slug == subject_slug)
    ).scalar_one_or_none()
    if not subject:
        raise HTTPException(status_code=404)

    # ── Step 2: validate subtopic (must belong to this subject) ───────
    subtopic = db.execute(
        select(Subtopic).where(
            Subtopic.slug       == subtopic_slug,
            Subtopic.subject_id == subject.id,
        )
    ).scalar_one_or_none()
    if not subtopic:
        raise HTTPException(status_code=404)

    # ── Step 3: validate page (must belong to this subtopic) ──────────
    page = db.execute(
        select(Page).where(
            Page.slug        == page_slug,
            Page.subtopic_id == subtopic.id,
        )
    ).scalar_one_or_none()
    if not page:
        raise HTTPException(status_code=404)

    # ── Step 4: load content sections ordered by display_order ────────
    content_sections = db.execute(
        select(PageContent)
        .where(PageContent.page_id == page.id)
        .order_by(PageContent.display_order)
    ).scalars().all()

    return templates.TemplateResponse(
        "page.html",
        base_context(
            request, db,
            subject=subject,
            subtopic=subtopic,
            page=page,
            content_sections=content_sections,
            active_subject=subject_slug,
        ),
    )
Why validate at each level? We check Subtopic.subject_id == subject.id (and Page.subtopic_id == subtopic.id) rather than just matching on the slug alone. Without this, a URL like /japan/grammar/kanji-tiles would return the kanji-tiles page even though it belongs to the kanji subtopic, not grammar — because the page slug alone would still match. The hierarchy check is what makes slugs meaningful.

3 What happens for every page request

Here is the full request flow for GET /japan/kanji/kanji-tiles. Any step that returns None from the database raises a 404.

Browser GET /japan/kanji/kanji-tiles
FastAPI router cms_page() called
DB query 1 subjects WHERE slug='japan'
↓ None
Result 404 Not Found
  subject found ✓
DB query 2 subtopics WHERE slug='kanji' AND subject_id=1
↓ None
Result 404 Not Found
  subtopic found ✓
DB query 3 pages WHERE slug='kanji-tiles' AND subtopic_id=4
↓ None
Result 404 Not Found
  page found ✓
DB query 4 page_content WHERE page_id=12 ORDER BY display_order
Result 200 — page.html rendered

4 Custom 404 error page

By default, FastAPI returns a JSON body for HTTPException. For a website, we want an HTML page instead. Register an exception handler on the app object in main.py:

app/main.py — add the exception handler python
from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from app import models  # noqa: F401
from app.database import get_db, get_subjects
from app.models import Subject
from app.routers import cms

app = FastAPI(title="osztromok.com")
templates = Jinja2Templates(directory="app/templates")

app.mount("/static",    StaticFiles(directory="app/static"),  name="static")
app.mount("/resources", StaticFiles(directory="resources"),    name="resources")

# ── Include the CMS router ────────────────────────────────────────────────
# No prefix — routes are registered exactly as written in cms.py
# Include AFTER static mounts so /static/ and /resources/ are never
# accidentally caught by the {subject_slug} catch-all route.
app.include_router(cms.router)

# ── Home route (stays in main.py) ────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def home(request: Request, db: Session = Depends(get_db)):
    subjects = get_subjects(db)
    return templates.TemplateResponse(
        "home.html", {"request": request, "subjects": subjects}
    )

# ── 404 handler — renders an HTML page instead of JSON ───────────────────
# @app.exception_handler catches HTTPException raises anywhere in the app.
# We can still access the DB here to populate the nav subjects.
@app.exception_handler(404)
async def not_found(request: Request, exc: HTTPException):
    # Open a fresh session (we can't use Depends() in exception handlers)
    from app.database import SessionLocal
    db = SessionLocal()
    try:
        subjects = get_subjects(db)
    finally:
        db.close()
    return templates.TemplateResponse(
        "404.html",
        {"request": request, "subjects": subjects},
        status_code=404,
    )
Why can't the 404 handler use Depends(get_db)? FastAPI's dependency injection is wired to route handlers — exception handlers are called outside the normal request lifecycle, after the route's dependencies have already been closed. We open a fresh SessionLocal() manually and close it in a finally block.

5 The four new templates

app/templates/page.html — the main CMS page

This template renders the actual content of a page. The key line is {{ section.content | safe }} — it injects the raw HTML stored in page_content.content directly into the page without escaping it. This is how your existing styled lesson content (kanji tiles, Japanese lessons, etc.) will appear exactly as it does now.

app/templates/page.html html + jinja2
{% extends "base.html" %}

{% block title %}{{ page.name }}{{ subject.name }} — osztromok.com{% endblock %}

{% block content %}
<div class="container">

  {# ── Breadcrumb ────────────────────────────────────────────── #}
  <nav class="breadcrumb" aria-label="breadcrumb">
    <a href="/">Home</a><a href="/{{ subject.slug }}">{{ subject.name }}</a><a href="/{{ subject.slug }}/{{ subtopic.slug }}">{{ subtopic.name }}</a>{{ page.name }}
  </nav>

  {# ── Page header ───────────────────────────────────────────── #}
  <div class="page-body">
    <h1>{{ page.name }}</h1>

    {# ── Content sections ──────────────────────────────────────── #}
    {# Each row in page_content is rendered in display_order.
       The | safe filter renders HTML as-is — this is how your styled
       lesson fragments (kanji tiles, vocabulary tables, etc.) appear. #}
    {% for section in content_sections %}
      {% if section.content_type == 'html' %}
        <div class="page-content">
          {{ section.content | safe }}
        </div>
      {% elif section.content_type == 'markdown' %}
        {# Markdown rendering — add python-markdown in Chapter 8 if needed #}
        <div class="page-content">{{ section.content | safe }}</div>
      {% endif %}
    {% else %}
      <p style="color:#6e7681">This page has no content yet.</p>
    {% endfor %}

  </div>{# .page-body #}
</div>{# .container #}
{% endblock %}

app/templates/subject.html — subject index

app/templates/subject.html html + jinja2
{% extends "base.html" %}

{% block title %}{{ subject.name }} — osztromok.com{% endblock %}

{% block content %}
<div class="container">

  <nav class="breadcrumb">
    <a href="/">Home</a>{{ subject.name }}
  </nav>

  <div class="subject-section">

    {% if subject.icon %}
      <div style="font-size:2.5rem;margin-bottom:8px">{{ subject.icon }}</div>
    {% endif %}
    <h1 style="margin-bottom:6px">{{ subject.name }}</h1>
    {% if subject.description %}
      <p style="color:#8b949e;margin-bottom:28px">{{ subject.description }}</p>
    {% endif %}

    <p class="section-title">{{ subject.subtopics | length }} subtopics</p>

    <div class="subject-grid">
      {% for subtopic in subject.subtopics %}
        <a href="/{{ subject.slug }}/{{ subtopic.slug }}" class="subject-card">
          <div class="card-name">{{ subtopic.name }}</div>
          {% if subtopic.description %}
            <div class="card-desc">{{ subtopic.description | truncate(90) }}</div>
          {% endif %}
          <div class="card-desc" style="margin-top:8px;color:#6e7681">
            {{ subtopic.pages | length }} page{% if subtopic.pages | length != 1 %}s{% endif %}
          </div>
        </a>
      {% endfor %}
    </div>

  </div>
</div>
{% endblock %}

app/templates/subtopic.html — page listing

app/templates/subtopic.html html + jinja2
{% extends "base.html" %}

{% block title %}{{ subtopic.name }}{{ subject.name }} — osztromok.com{% endblock %}

{% block content %}
<div class="container">

  <nav class="breadcrumb">
    <a href="/">Home</a><a href="/{{ subject.slug }}">{{ subject.name }}</a>{{ subtopic.name }}
  </nav>

  <div class="subject-section">
    <h1 style="margin-bottom:6px">{{ subtopic.name }}</h1>
    {% if subtopic.description %}
      <p style="color:#8b949e;margin-bottom:28px">{{ subtopic.description }}</p>
    {% endif %}

    <p class="section-title">{{ subtopic.pages | length }} page{% if subtopic.pages | length != 1 %}s{% endif %}</p>

    <div class="subject-grid">
      {% for pg in subtopic.pages %}
        <a href="/{{ subject.slug }}/{{ subtopic.slug }}/{{ pg.slug }}" class="subject-card">
          <div class="card-name">{{ pg.name }}</div>
        </a>
      {% else %}
        <p style="color:#6e7681">No pages in this subtopic yet.</p>
      {% endfor %}
    </div>
  </div>
</div>
{% endblock %}

app/templates/404.html — error page

app/templates/404.html html + jinja2
{% extends "base.html" %}

{% block title %}404 — Page Not Found — osztromok.com{% endblock %}

{% block content %}
<div style="text-align:center;padding:80px 24px">
  <div style="font-size:5rem;margin-bottom:16px">404</div>
  <h1 style="color:#00b4d8;font-size:1.4rem;margin-bottom:10px">
    Page not found
  </h1>
  <p style="color:#8b949e;max-width:400px;margin:0 auto 28px">
    The URL you requested doesn’t match any subject, subtopic, or page
    in the database. Check the spelling or start from the home page.
  </p>
  <a href="/"
     style="display:inline-block;background:#00b4d8;color:#000;
            font-weight:700;padding:10px 24px;border-radius:6px;
            text-decoration:none;font-size:.9rem">
    ← Back to home
  </a>
</div>
{% endblock %}

6 Testing the routes

With the dev server running, test these URLs using your actual slugs from the database. Replace the example slugs with ones that match your data:

URLs to test in your browser
# Home — should show subject cards
http://127.0.0.1:8000/

# Subject index — should show subtopic cards for Japan
http://127.0.0.1:8000/japan

# Subtopic index — should list pages in the Kanji subtopic
http://127.0.0.1:8000/japan/kanji

# Page content — should render your stored HTML (kanji tile grid)
http://127.0.0.1:8000/japan/kanji/kanji-tiles

# 404 — should render your styled 404 page (not a JSON error)
http://127.0.0.1:8000/japan/kanji/this-page-does-not-exist
http://127.0.0.1:8000/does-not-exist

Troubleshooting checklist

SymptomLikely causeFix
JSON {"detail":"Not Found"} instead of HTML 404 Exception handler not registered, or registered after include_router Move @app.exception_handler(404) to main.py, before include_router
Page renders but nav is empty subjects not in template context Ensure get_subjects(db) is called and "subjects" key is in the dict
Content renders as escaped HTML text Missing | safe on content output Change {{ section.content }} to {{ section.content | safe }}
/japan/kanji/kanji-tiles returns 404 unexpectedly Slug mismatch between URL and database value Check exact slug values: SELECT slug FROM pages; in MySQL
Static CSS not loading on page StaticFiles mount order or wrong path in template Confirm url_for('static', path='css/main.css') in base.html head

✓ Chapter 4 Complete — Milestone reached

  • app/routers/cms.py — three routes handle /{subject}, /{subject}/{subtopic}, and /{subject}/{subtopic}/{page}
  • Slug validation — each level checks that slugs belong to their parent, preventing cross-hierarchy collisions
  • 404 handling — any invalid slug at any level raises HTTPException and renders a styled HTML page
  • page.html — renders page_content HTML with | safe; multiple content sections stacked in display order
  • subject.html / subtopic.html — index pages with breadcrumb navigation
  • 404.html — styled error page with a return-home button
  • All your existing CMS content is now accessible via FastAPI — same URLs, same rendered output

Quick Reference — Chapter 4

router = APIRouter() Create a route group in its own file
app.include_router(cms.router) Register a router's routes on the main app
raise HTTPException(status_code=404) Return a 404 from any route handler
@app.exception_handler(404) Intercept 404s and return a custom HTML response
db.execute(select(Subject).where(Subject.slug == x)).scalar_one_or_none() Fetch one row or None — never throws on missing row
{{ section.content | safe }} Render trusted HTML from the database without escaping
@router.get("/{subject_slug}/{subtopic_slug}/{page_slug}") Three-segment path with all three slugs as parameters
Page.subtopic_id == subtopic.id Scoped query — ensures the page belongs to the right subtopic