More Routing

FastAPI Rebuild › Chapter 6

Flexible Routing

Special routes, the kanji character URL, path convertors, and why the Apache 403 can never happen here

Chapter 6 of 10 Route Priority {path:path} RedirectResponse
Chapter Milestone

By the end of this chapter /japan/kanji/水 (and any other kanji character) is a working URL that serves the kanji detail page. Standard CMS pages in the same subtopic (kanji-tiles, kanji-links) still work via the database. A special router handles non-standard paths cleanly without touching the generic CMS logic.

1 Why the old Apache problem cannot happen with FastAPI

Recall the 403 error that triggered this entire rebuild: you created a folder /var/www/html/japan/ on the server and Apache's .htaccess rule RewriteCond %{REQUEST_FILENAME} !-d saw a real directory at that path and bypassed the PHP router — serving a 403 instead.

FastAPI has no equivalent fragility because routing is pure Python code, evaluated top-to-bottom, with no filesystem checks involved. The ASGI stack handles requests in a fixed layer order:

1 StaticFiles mount: /static/ serves app/static/ — CSS, JS, images
▼ only if path does not start with /static/
2 StaticFiles mount: /resources/ serves resources/ — kanji HTML, PDFs, any static files
▼ only if path does not start with /resources/
3 special.py routes registered first — intercepts non-standard paths
▼ only if no special route matched
4 cms.py routes /{subject}, /{subject}/{subtopic}, /{subject}/{subtopic}/{page}
▼ only if no CMS route matched
5 404 exception handler renders styled HTML error page

The StaticFiles mounts are ASGI middleware — they short-circuit immediately when a path prefix matches, before any route function is even considered. There is no directory-existence check. There is no .htaccess. You could create a folder called japan/ anywhere on the server and FastAPI would never know or care — it only sees the URL, not the filesystem.

Practical consequence: your resources/japanese/kanji/ directory exists on the server's filesystem. FastAPI serves files from it at /resources/japanese/kanji/ because of the explicit mount — not because Apache's !-d let a directory listing through. If you remove the mount, those files become completely unreachable. You are always in control.

2 Route priority — first match wins

FastAPI builds a route table in registration order. When a request arrives, it walks the table until the first pattern matches. More specific patterns must come before more general ones, or they will never be reached.

URLWhat it should serveHandled by
/static/css/main.css The site stylesheet StaticFiles
/resources/japanese/kanji/kanji_水.html The raw kanji detail page StaticFiles
/japan/kanji/水 Clean kanji URL → redirect or render inline special.py
/japan/kanji/kanji-tiles CMS page from database (the tile grid) special.py → DB lookup
/japan/kanji/kanji-links CMS page from database (the links page) special.py → DB lookup
/japan/kanji Kanji subtopic index — list of pages cms.py
/japan Japan subject index — list of subtopics cms.py
/french/grammar/adjectives CMS page — 3 normal ASCII slugs cms.py
Why does special.py handle kanji-tiles and kanji-links? Because /japan/kanji/{page_slug} in special.py is a 3-segment route where the middle segment is literally the string "kanji". It will match any 3-segment URL where the second part is kanji — including both kanji characters (水) and ASCII slugs (kanji-tiles). Once you register a special route for a path, that route owns all matching URLs. The handler detects which case it is and acts accordingly.

3 Creating app/routers/special.py

This router holds all routes that need custom logic beyond the standard three-level CMS hierarchy. Today it has one route — the kanji character handler. Over time it might grow to include API endpoints, search, download links, or content from a fourth nesting level.

app/routers/special.py python
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from app.dependencies import CommonContext
from app.models import Subject, Subtopic, Page, PageContent

router = APIRouter()
templates = Jinja2Templates(directory="app/templates")

# ── kanji resources live here on disk ────────────────────────────────────
KANJI_RESOURCE_DIR = Path("resources/japanese/kanji")


# ═══════════════════════════════════════════════════════════════════════════
# /japan/kanji/{page_slug}
#
# This route intercepts ALL 3-segment URLs where the second segment is
# "kanji" — because it is registered BEFORE the generic CMS route in main.py.
#
# It handles two distinct cases:
#
#   Case A — page_slug is a kanji character (non-ASCII), e.g. 水, 食, 飲
#             → look for kanji_{char}.html in the resources folder
#             → redirect to /resources/japanese/kanji/kanji_{char}.html
#               (or serve 404 if the file doesn't exist yet)
#
#   Case B — page_slug is an ASCII slug, e.g. kanji-tiles, kanji-links
#             → normal database lookup, exactly like the generic CMS route
#             → render page.html with the stored HTML content
#
# Why handle both here? Because FastAPI matched this route first. Once a route
# matches, there is no "fall through" to the next route. We own this path.
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/{subject_slug}/kanji/{page_slug}", response_class=HTMLResponse)
async def kanji_page(
    subject_slug: str,
    page_slug:    str,
    ctx: CommonContext = Depends(),
):
    # Validate subject exists regardless of which case we're in
    subject = ctx.db.execute(
        select(Subject).where(Subject.slug == subject_slug)
    ).scalar_one_or_none()
    if not subject:
        raise HTTPException(status_code=404)

    # ── Case A: kanji character (contains non-ASCII) ─────────────────────
    if not page_slug.isascii():
        return _serve_kanji_character(page_slug)

    # ── Case B: ASCII slug — standard database lookup ─────────────────────
    return await _serve_kanji_cms_page(subject, page_slug, ctx)


def _serve_kanji_character(character: str) -> RedirectResponse:
    """Redirect /japan/kanji/水 → /resources/japanese/kanji/kanji_水.html

    The kanji detail pages are full standalone HTML files in the resources
    folder. We redirect to the static URL so the browser loads the file
    directly — no template rendering needed.

    In a future enhancement you could instead read the file content and
    inject it into the base template so the site nav and breadcrumbs appear.
    """
    resource_file = KANJI_RESOURCE_DIR / f"kanji_{character}.html"

    if not resource_file.exists():
        raise HTTPException(
            status_code=404,
            detail=f"No kanji page found for '{character}'. "
                   f"Generate it with: Kanji {character}",
        )

    # 302 redirect — browser follows it to the static resource URL
    return RedirectResponse(
        url=f"/resources/japanese/kanji/kanji_{character}.html",
        status_code=302,
    )


async def _serve_kanji_cms_page(
    subject:  Subject,
    page_slug: str,
    ctx:      CommonContext,
):
    """Look up and render an ASCII-slug page within the kanji subtopic."""
    subtopic = ctx.db.execute(
        select(Subtopic).where(
            Subtopic.slug       == "kanji",
            Subtopic.subject_id == subject.id,
        )
    ).scalar_one_or_none()
    if not subtopic:
        raise HTTPException(status_code=404)

    page = ctx.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)

    content_sections = ctx.db.execute(
        select(PageContent)
        .where(PageContent.page_id == page.id)
        .order_by(PageContent.display_order)
    ).scalars().all()

    from app.routers.cms import _prev_next
    prev_page, next_page = _prev_next(subtopic.pages, page.id)

    return templates.TemplateResponse(
        "page.html",
        ctx.get(
            subject=subject,
            subtopic=subtopic,
            page=page,
            content_sections=content_sections,
            prev_page=prev_page,
            next_page=next_page,
            breadcrumbs=[
                {"label": "Home",          "url": "/"},
                {"label": subject.name,   "url": f"/{subject.slug}"},
                {"label": subtopic.name, "url": f"/{subject.slug}/{subtopic.slug}"},
                {"label": page.name,     "url": None},
            ],
        ),
    )
str.isascii() returns True if every character in the string is in the ASCII range (0–127). Kanji characters are Unicode code points far above 127, so "水".isascii() is False while "kanji-tiles".isascii() is True. This is a clean, zero-regex way to distinguish the two cases.

4 Register special.py before cms.py in main.py

Registration order is everything. Update app/main.py so special.router is included before cms.router. If you get this wrong, /japan/kanji/水 will be caught by the generic three-segment CMS route first — which will look for a page with slug "水" in the database, find nothing, and return 404.

app/main.py — router registration section python
from app.routers import cms, special

# ── Static mounts (checked before ANY route) ─────────────────────────────
app.mount("/static",    StaticFiles(directory="app/static"),  name="static")
app.mount("/resources", StaticFiles(directory="resources"),    name="resources")

# ── Routers — ORDER MATTERS ───────────────────────────────────────────────
# special must come before cms so that /{subject}/kanji/{char} routes
# are checked before the generic /{subject}/{subtopic}/{page} catch-all.
app.include_router(special.router)   # ← first: specific special-case routes
app.include_router(cms.router)       # ← second: general CMS three-level routes
The home route in main.py stays above both routers — it is defined directly on app, which means it is registered first. Routes on the main app are always evaluated before routes on included routers.

5 The {path:path} convertor — unlimited depth

Starlette (which FastAPI is built on) provides path convertors that change how a path parameter is matched. The path convertor matches any string including forward slashes — meaning it can capture multiple URL segments as a single parameter.

Path convertor examples python
# Standard string parameter — matches exactly ONE segment (no slashes)
@router.get("/{subject_slug}/{subtopic_slug}/{page_slug}")
async def cms_page(subject_slug: str, subtopic_slug: str, page_slug: str):
    pass
# Matches:  /japan/kanji/kanji-tiles   → all three parameters filled
# No match: /japan/kanji/topic/sub-item  → 4 segments, won't match this


# path convertor — matches ANY number of segments including slashes
@router.get("/{full_path:path}")
async def deep_route(full_path: str):
    parts = full_path.strip("/").split("/")
    pass
# Matches:  /japan/kanji/kanji-tiles     → full_path = "japan/kanji/kanji-tiles"
# Matches:  /japan/kanji/topic/sub/item  → full_path = "japan/kanji/topic/sub/item"
# Matches:  /anything/at/all/here        → full_path = "anything/at/all/here"


# int convertor — only matches if the segment is a valid integer
@router.get("/page/{page_id:int}")
async def page_by_id(page_id: int):  # already an int, not a string
    pass
# Matches:  /page/42    → page_id = 42
# No match: /page/hello  → skips this route, tries the next one

When to use {path:path}

The path convertor is the escape hatch for deeply nested content that can't be predicted at design time. A few practical cases for osztromok.com:

  • Future course chapters — a bash lesson at /python/web/fastapi/chapter-3/exercise-1 would be five levels deep; a {path:path} route can parse this at runtime
  • Kanji detail pages with context — you might want /japan/kanji/water/stroke-3 to highlight a specific stroke
  • Resource browser/files/{path:path} for browsing a folder hierarchy
Catch-all must be last. A {path:path} route matches everything. If you register it before other routes, nothing else will ever be reached. Always put it at the very end of your router — after all static mounts, special routes, and CMS routes.
app/routers/cms.py — optional future catch-all (add last) python
# ═══════════════════════════════════════════════════════════════════════════
# Catch-all: anything not matched by the routes above
# This runs LAST — only add this when you need it for deep/dynamic paths.
# Currently not needed; add in a future chapter if the site grows deeper.
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/{full_path:path}", response_class=HTMLResponse, include_in_schema=False)
async def deep_catch_all(
    full_path: str,
    ctx: CommonContext = Depends(),
):
    # Parse the segments manually and try to match them to DB records
    # This is where you'd add 4-level or 5-level routing logic in future
    parts = [p for p in full_path.strip("/").split("/") if p]
    # For now, any unknown path gets a 404
    raise HTTPException(status_code=404)

The include_in_schema=False argument hides the catch-all from the auto-generated /docs Swagger page — it would otherwise show up as a confusingly generic route entry.

6 Update the kanji tile grid to use clean URLs

Now that /japan/kanji/水 works, the kanji tile grid should link to the clean URL rather than the raw /resources/… path. Update the kanjiList array in both kanji_tiles_cms_content.html (the CMS page fragment) and anywhere else that hard-codes the resource path.

kanjiList — update file values in kanji_tiles_cms_content.html javascript
// Before — links to the raw resource path
const kanjiList = [
  { kanji: '水', file: '/resources/japanese/kanji/kanji_水.html', meaning: 'Water' },
  { kanji: '食', file: '/resources/japanese/kanji/kanji_食.html', meaning: 'Eat / Food' },
  { kanji: '飲', file: '/resources/japanese/kanji/kanji_飲.html', meaning: 'Drink / Swallow' },
];

// After — links to the clean CMS-style URL
// FastAPI's special.py redirects /japan/kanji/水 → /resources/…
const kanjiList = [
  { kanji: '水', file: '/japan/kanji/水', meaning: 'Water' },
  { kanji: '食', file: '/japan/kanji/食', meaning: 'Eat / Food' },
  { kanji: '飲', file: '/japan/kanji/飲', meaning: 'Drink / Swallow' },
];
Forward-compatible: when you eventually refactor the kanji pages to render inside the site's base template (with nav, breadcrumbs, and prev/next), nothing in the tile grid needs to change — the URL stays the same. The redirect in special.py is the only thing that changes.

7 RedirectResponse — the three status codes

FastAPI's RedirectResponse sends an HTTP redirect header. The status_code argument controls how browsers and search engines treat it:

Redirect status codes python
from fastapi.responses import RedirectResponse

# 301 Moved Permanently
# Browser caches this. Future requests go directly to the new URL.
# Use for: canonical URL changes, old URLs you're retiring forever.
# Caution: very hard to undo — cached in browsers indefinitely.
return RedirectResponse(url="/new-path", status_code=301)

# 302 Found (temporary redirect) — default in FastAPI
# Browser does NOT cache. Every request re-checks the original URL.
# Use for: kanji character → resource file (we might change this later)
return RedirectResponse(url="/new-path", status_code=302)

# 307 Temporary Redirect
# Like 302 but guarantees the HTTP method is preserved (POST stays POST).
# Use for: form submissions that redirect to a different handler.
return RedirectResponse(url="/new-path", status_code=307)

We use 302 for the kanji character redirect because the current destination (/resources/…) might change in the future — perhaps we will inline the kanji content inside the base template. A 302 ensures every request goes through special.py and can be updated without browser cache issues.

8 Testing all the URL cases

URLs to verify in your browser
# StaticFiles — served before any route runs
http://127.0.0.1:8000/resources/japanese/kanji/kanji_水.html
# → loads the standalone kanji page directly (no redirect)

# Special route — kanji character case (non-ASCII)
http://127.0.0.1:8000/japan/kanji/水
# → 302 redirect → /resources/japanese/kanji/kanji_水.html → kanji page

http://127.0.0.1:8000/japan/kanji/食
# → 302 redirect → kanji_食.html

http://127.0.0.1:8000/japan/kanji/飲
# → 302 redirect → kanji_飲.html

# Special route — ASCII slug case (DB lookup)
http://127.0.0.1:8000/japan/kanji/kanji-tiles
# → renders CMS page from database (the mahjong tile grid)

http://127.0.0.1:8000/japan/kanji/kanji-links
# → renders CMS page from database (the links page)

# CMS route — generic (unaffected)
http://127.0.0.1:8000/japan/kanji
# → subtopic index listing pages

http://127.0.0.1:8000/france/grammar/subjunctive
# → generic CMS route handles this (no special route registered for "grammar")

# 404 cases
http://127.0.0.1:8000/japan/kanji/龍
# → special route fires, file doesn't exist yet → 404 with helpful message

http://127.0.0.1:8000/japan/kanji/not-a-real-page
# → special route fires, ASCII slug, DB lookup → not found → 404

Watching the redirect in browser DevTools

Open DevTools → Network tab, then visit /japan/kanji/水. You will see two requests: a 302 response to /japan/kanji/水 followed by a 200 response to /resources/japanese/kanji/kanji_水.html. This confirms the redirect chain is working correctly. The kanji tile grid's href values point to the clean URL, but the browser ultimately loads the standalone resource page.

9 Extending special.py for future needs

Any time you need a route that doesn't fit the standard three-level CMS pattern, add it to special.py. Here are the most likely additions for osztromok.com as the site grows:

app/routers/special.py — examples of future routes python
# ── A search endpoint ────────────────────────────────────────────────────
# GET /search?q=kanji
# Would sit above the CMS routes so /search is not treated as a subject slug
@router.get("/search", response_class=HTMLResponse)
async def search(q: str = "", ctx: CommonContext = Depends()):
    pass  # implement in future


# ── An RSS feed ───────────────────────────────────────────────────────────
# GET /feed.xml — must be above /{subject_slug} or "feed.xml" becomes a subject
@router.get("/feed.xml")
async def rss_feed(ctx: CommonContext = Depends()):
    pass


# ── A four-level page for a language course with categories ───────────────
# GET /python/web/fastapi/routing
# subject / subtopic / category / page  (4 levels deep)
@router.get("/python/{subtopic_slug}/{category_slug}/{page_slug}",
           response_class=HTMLResponse)
async def python_deep_page(
    subtopic_slug:  str,
    category_slug:  str,
    page_slug:      str,
    ctx: CommonContext = Depends(),
):
    pass
The pattern to remember: anything that would conflict with /{subject_slug} (because it uses the same URL position) goes into special.py and is registered before cms.py. Routes like /search, /sitemap.xml, /about, and /favicon.ico would all be caught as subject slugs otherwise.

✓ Chapter 6 Complete — Milestone reached

  • app/routers/special.py — dedicated router for non-CMS paths, registered before cms.py
  • /japan/kanji/水 (and 食, 飲, any future kanji) — routes through special.py, redirects to the resource file via 302
  • /japan/kanji/kanji-tiles and kanji-links — also handled by special.py via DB lookup; generic CMS route never sees them
  • str.isascii() — clean way to distinguish kanji characters from ASCII page slugs without regex
  • {path:path} convertor — documented for future deep/unlimited nesting; catch-all registered last
  • StaticFiles ASGI advantage — /static/ and /resources/ checked before any route; no .htaccess; no !-d filesystem race condition
  • Kanji tile grid links — updated from /resources/… to /japan/kanji/水 clean URLs

Quick Reference — Chapter 6

app.include_router(special.router) # before cms! Register special routes before generic CMS routes
RedirectResponse(url="/path", status_code=302) Temporary redirect — browser follows, not cached
"水".isascii() → False Detect kanji/Unicode characters without regex
Path("resources/kanji/kanji_水.html").exists() Check if a resource file exists before redirecting
@router.get("/{full_path:path}") Catch-all that matches any depth URL — register last
@router.get("/page/{id:int}") int convertor — only matches numeric segments
include_in_schema=False Hide a route from the /docs Swagger UI
StaticFiles mount is ASGI middleware Checked before any route handler — no filesystem conflict possible