routing

Routing utilities for FastHTML applications

Route Registration Helper

Helper function to register multiple routers at once, reducing boilerplate.


register_routes


def register_routes(
    app, # FastHTML app instance
    routers:VAR_POSITIONAL, # One or more APIRouter instances to register
)->None: # No return value

Register multiple APIRouter instances to a FastHTML app at once.

Example Usage

# Mock FastHTML app and routers for testing
class MockRouter:
    def __init__(self, prefix):
        self.prefix = prefix
        self.registered = False
    
    def to_app(self, app):
        self.registered = True
        app.routers.append(self)

class MockApp:
    def __init__(self):
        self.routers = []

# Create mock app and routers
app = MockApp()
main_router = MockRouter("/")
settings_router = MockRouter("/settings")
api_router = MockRouter("/api")

# Register all at once
register_routes(app, main_router, settings_router, api_router)

# Verify all registered
print(f"Number of routers registered: {len(app.routers)}")
for router in app.routers:
    print(f"  - {router.prefix}: registered={router.registered}")
Number of routers registered: 3
  - /: registered=True
  - /settings: registered=True
  - /api: registered=True

Benefits

This simple helper provides:

  • Reduced boilerplate: One line instead of N lines for N routers
  • Clear intent: Explicitly shows “these are all the routes for this app”
  • Easier maintenance: Add/remove routers in one place
  • Less error-prone: Can’t accidentally forget to register a router

Flat-URL APIRouter (FastHTML 0.14 compatibility)

A thin APIRouter subclass that restores pre-0.14 flat URL behavior for routes defined inside factory functions. Consumers import this in place of fasthtml.common.APIRouter.

Background

FastHTML 0.14.0 introduced nested_name(f) (in fasthtml/core.py), which derives route URLs and rt_funcs attribute names from func.__qualname__ rather than func.__name__. For routes defined inside a factory function — the standard init_*_router(prefix) pattern across this ecosystem — __qualname__ includes the containing function’s name:

def init_card_stack_router(prefix='/cs'):
    rt = APIRouter(prefix)

    @rt
    def nav_up(): pass   # __qualname__ = 'init_card_stack_router.<locals>.nav_up'

    # FastHTML 0.13.x: nav_up.to() == '/cs/nav_up'
    # FastHTML 0.14.x: nav_up.to() == '/cs/init_card_stack_router_nav_up'

FastHTML 0.14 exposes no flag to disable this. Per design-system P9 (long-term solutions over quick fixes) and P11 (change-tolerant by design), we override APIRouter once here and consumers import from cjm_fasthtml_app_core.core.routing. When upstream FastHTML eventually exposes a flat_qualname=True (or equivalent) kwarg — likely, since the conflict will surface for other factory-pattern users — the revert is a single-file change behind the stable namespace.


APIRouter


def APIRouter(
    prefix:str | None=None, body_wrap:function=noop_body
):

APIRouter override that derives route URLs and rt_funcs names from func.__name__ rather than FastHTML 0.14’s nested_name() (which prepends the containing function’s name to routes defined inside factory functions).

Restores pre-0.14 flat URL behavior for the init_*_router(prefix) factory pattern used across the cjm-* ecosystem.

Also overrides __getattr__ to raise a clean AttributeError when an attribute lookup falls through. FastHTML 0.14’s base implementation calls super().__getattr__(self, name), but object has no __getattr__, so that path produces a confusing 'super' object has no attribute '__getattr__' error instead of the expected AttributeError for any non-route attribute access.

Test

# Test cell: validate flat URLs across all five route patterns + clean __getattr__.
# Mirrors the init_*_router factory pattern used across the cjm-* ecosystem
# (the pattern that surfaced the FastHTML 0.14 incompatibility).

def _test_apirouter_flat_urls():
    """Factory-pattern routes produce flat URLs (pre-0.14 behavior)."""
    rt = APIRouter('/cs')

    @rt
    def nav_up(): pass

    @rt
    def update_viewport(): pass

    @rt('/custom_path')
    def some_handler(): pass

    @rt.get
    def fetch_data(): pass

    @rt.post('/explicit_post')
    def submit_form(): pass

    @rt
    def index(): pass

    # Bare @rt: URL from func.__name__, not __qualname__-derived nested name
    assert nav_up.to() == '/cs/nav_up', f'got {nav_up.to()!r}'
    assert update_viewport.to() == '/cs/update_viewport', f'got {update_viewport.to()!r}'

    # Explicit path: preserved verbatim
    assert some_handler.to() == '/cs/custom_path', f'got {some_handler.to()!r}'

    # HTTP-method shortcut (bare): flat URL — confirms partialmethod re-bind works
    assert fetch_data.to() == '/cs/fetch_data', f'got {fetch_data.to()!r}'

    # HTTP-method shortcut (explicit): preserved
    assert submit_form.to() == '/cs/explicit_post', f'got {submit_form.to()!r}'

    # index() special case: prefix + '/' (matches FastHTML's existing convention)
    assert index.to() == '/cs/', f'got {index.to()!r}'

    # rt_funcs accessibility uses flat func.__name__
    assert hasattr(rt.rt_funcs, 'nav_up')
    assert hasattr(rt.rt_funcs, 'some_handler')
    assert hasattr(rt.rt_funcs, 'fetch_data')
    assert hasattr(rt.rt_funcs, 'submit_form')

    # HTTP methods registered correctly through the re-bound partialmethods
    routes_by_func = {r[0].__name__: r for r in rt.routes}
    assert routes_by_func['fetch_data'][2] == 'get'
    assert routes_by_func['submit_form'][2] == 'post'

    return rt


def _test_apirouter_getattr_raises_clean_error():
    """Non-route attribute access raises clean AttributeError, not the FastHTML
    0.14 base-class `'super' object has no attribute '__getattr__'` error.
    """
    rt = APIRouter('/cs')

    @rt
    def known_route(): pass

    # Known route is accessible via rt.<name> (rt_funcs delegation)
    assert rt.known_route is not None

    # Unknown attribute raises clean AttributeError, not the upstream-FastHTML-0.14
    # 'super' object error
    try:
        _ = rt.this_attribute_does_not_exist
    except AttributeError as e:
        msg = str(e)
        assert 'this_attribute_does_not_exist' in msg, f"AttributeError msg: {msg!r}"
        assert 'super' not in msg, (
            f"AttributeError leaked the FastHTML 0.14 'super' fallthrough error: {msg!r}"
        )
    else:
        raise AssertionError("expected AttributeError on unknown attribute")


_test_apirouter_flat_urls()
_test_apirouter_getattr_raises_clean_error()
print('✓ All flat-URL APIRouter assertions pass.')
print('✓ Clean __getattr__ AttributeError verified.')