Flexible Routing
Flexible Routing
Special routes, the kanji character URL, path convertors, and why the Apache 403 can never happen here
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:
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.
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.
| URL | What it should serve | Handled 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 |
/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.
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}, ], ), )
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.
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
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.
# 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-1would 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-3to highlight a specific stroke - Resource browser —
/files/{path:path}for browsing a folder hierarchy
{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.
# ═══════════════════════════════════════════════════════════════════════════ # 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.
// 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' }, ];
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:
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
# 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:
# ── 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
/{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