Routing

Purpose: Request Delivery — How requests reach handlers based on path, HTTP method and headers.

Routing is the first step in handling requests - it determines which handler function should process an incoming HTTP request based on the path, method, and optionally, headers.

Stario's routing system is built on top of Starlette's router, adding opinionated patterns and conveniences for building hypermedia-driven applications.

Application Setup

The Stario class is the main entry point for your application. It configures routing, middleware, exception handlers, and logging. To some extent it's just a main router, with some extra features.

The simplest way to define routes is using decorators on your handler functions:

from stario import Stario

app = Stario()

@app.query("/")
async def index():
    return div("Welcome")

@app.query("/users/{user_id}")
async def get_user(user_id: PathParam[int]):
    return div(f"User {user_id}")

@app.command("/users")
async def create_user(name: QueryParam[str]):
    return div(f"Created: {name}")

@app.detached_command("/import")
async def import_data():
    # Runs in background, returns 204 immediately
    await long_running_import()

Available decorators:

  • @app.query(path) - Read operations (GET)
  • @app.command(path) - Write operations (POST)
  • @app.detached_command(path) - Fire-and-forget operations (POST, background execution)

Alternative: Route Constructor Approach

If you prefer to define routes without decorators, pass them to the constructor or use app.add():

from stario import Stario
from stario.routes import Query, Command, DetachedCommand

app = Stario(
    Query("/", index),
    Query("/users/{user_id}", get_user),
    Command("/users", create_user),
    DetachedCommand("/import", import_data),
)

# Or add routes dynamically
app.add(Query("/search", search_results))

Constructor Parameters

Stario(
    *routes: BaseRoute,
    middleware: Sequence[Middleware] | None = None,
    compression_middleware: Middleware | None = BrotliMiddleware.as_middleware(),
    exception_handlers: Mapping[Any, ExceptionHandler] | None = None,
    lifespan: Lifespan[AppType] | None = None,
    debug: bool = False,
    router_class: type[StarRouter] = StarRouter,
    log_sinks: Sequence[Any] | None = None,
)
  • routes: Variable number of route objects to register
  • middleware: List of middleware to apply (see Middlewares)
  • compression_middleware: Response compression (Brotli by default)
  • exception_handlers: Map exception types or status codes to handler functions
  • lifespan: Context manager for startup/shutdown tasks
  • debug: Enable debug mode (affects error responses and logging)
  • router_class: Router class to use (default: StarRouter)
  • log_sinks: Custom logging sinks (auto-selected by default)

Application Methods

Adding routes dynamically:

app.add(Query("/new-route", handler))

Mounting sub-applications or routers:

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

Host-based routing:

app.host("api.example.com", api_app, name="api")

Adding middleware after initialization:

app.add_middleware(CORSMiddleware, allow_origins=["*"])

Adding exception handlers:

app.add_exception_handler(404, custom_404_handler)
app.add_exception_handler(ValueError, handle_value_error)

State and caching:

# Access application state
app.state.db_pool = create_pool()

# Internal cache for singleton dependencies
app.state.cache  # Automatically initialized

StarRouter

StarRouter is Stario's router class, designed to organize routes in modular applications. It's particularly useful in multi-module projects where you want to group related routes together.

from stario.routing import StarRouter
from stario.routes import Query, Command

# In api/users.py
users_router = StarRouter(
    Query("/", list_users),
    Query("/{user_id}", get_user),
    Command("/", create_user),
)

# In main app
app = Stario()
app.mount("/users", users_router)

Router Parameters

StarRouter(
    *routes: BaseRoute,
    redirect_slashes: bool = True,
    default: ASGIApp | None = None,
    lifespan: Lifespan[Any] | None = None,
    middleware: Sequence[Middleware] | None = None,
)

For larger applications, organize routes by feature or domain:

myapp/
├── app/
│   ├── main.py           # Main application
│   ├── users/
│   │   ├── __init__.py
│   │   └── router.py     # Users router
│   ├── posts/
│   │   ├── __init__.py
│   │   └── router.py     # Posts router
│   └── api/
│       ├── __init__.py
│       └── router.py     # API router

users/router.py:

from stario.routing import StarRouter
from stario.routes import Query, Command
from .handlers import list_users, get_user, create_user, update_user

router = StarRouter(
    Query("/", list_users, name="list_users"),
    Query("/{user_id}", get_user, name="get_user"),
    Command("/", create_user, name="create_user"),
    Command("/{user_id}", update_user, name="update_user"),
)

main.py:

from stario import Stario
from .users.router import router as users_router
from .posts.router import router as posts_router
from .api.router import router as api_router

app = Stario()
app.mount("/users", users_router)
app.mount("/posts", posts_router)
app.mount("/api", api_router)

Using Route Constructors

Instead of decorators, you can pass routes to the constructor. For the complete syntax of each route type, see Endpoints.

from stario import Stario
from stario.routes import Query, Command, DetachedCommand

app = Stario(
    Query("/", index, name="home"),
    Query("/users/{user_id}", get_user, name="user_detail"),
    Command("/users", create_user, name="create_user"),
    DetachedCommand("/import", import_data),
)

Or add them dynamically:

app.add(Query("/search", search_results))
app.add(Command("/posts", create_post))

Path Parameters

Use curly braces to define dynamic path segments:

Query("/users/{user_id}", get_user)
Query("/posts/{post_id}/comments/{comment_id}", get_comment)

Extract them in your handler using the PathParam dependency:

from stario.requests import PathParam
from stario.html import div

async def get_user(user_id: PathParam[int]):
    # Type annotation determines conversion: int → 123 (not "123")
    return div(f"User ID: {user_id}")

Type Conversion

Stario supports two approaches to specify path parameter types:

Approach 1: Type annotation (recommended)

# Type comes from the PathParam annotation
async def handler(post_id: PathParam[int]):
    # post_id is automatically converted to int
    pass

Approach 2: Starlette converters (explicit)

# Type specified in path using Starlette syntax
Query("/posts/{post_id:int}", handler)

# Supported converters:
Query("/items/{item_id:int}", handler)      # Integer
Query("/files/{filename:path}", handler)    # Path (includes slashes)
Query("/tags/{tag:str}", handler)           # String (default)

Both approaches are equivalent. Use whichever is more readable for your use case.

Mounting

Mounting Routers

Mount routers at specific path prefixes:

app.mount("/users", users_router)
app.mount("/admin", admin_router)

All routes in the mounted router will be prefixed with the mount path:

  • users_router with route "/" becomes /users/
  • users_router with route "/{user_id}" becomes /users/{user_id}

Static Files

Use Starlette's StaticFiles to serve static assets:

from starlette.staticfiles import StaticFiles

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

Then reference them in your HTML:

from stario.html import link, script

link({"rel": "stylesheet", "href": "/static/css/styles.css"})
script({"src": "/static/js/app.js"})

Or use url_path_for() for type-safe URLs:

app.url_path_for("static", path="css/styles.css")
# Returns: "/static/css/styles.css"

Mounting ASGI Apps

Mount any ASGI application:

from some_framework import create_app

legacy_app = create_app()
app.mount("/legacy", legacy_app)

URL Generation

Stario borrows url_path_for() from Starlette, enabling type-safe URL generation from route names.

Basic Usage

# In your routes
Query("/", index, name="home")
Query("/users/{user_id}", get_user, name="user_detail")

# Generate URLs
app.url_path_for("home")  # "/"
app.url_path_for("user_detail", user_id=123)  # "/users/123"

In Templates

from stario.requests import Request

async def navigation(request: Request):
    return nav(
        a({"href": request.app.url_path_for("home")}, "Home"),
        a({"href": request.app.url_path_for("user_detail", user_id=current_user_id)}, "Profile"),
    )

In Datastar Actions

The Datastar dependency automatically uses url_path_for() when you pass a route name (without leading /):

from stario.datastar import Datastar

async def my_button(ds: Datastar):
    return button(
        ds.on("click", ds.get("user_detail", user_id=123)),
        "Load User",
    )

If the URI starts with /, it's treated as an absolute path:

ds.get("/api/users")  # Absolute path
ds.get("user_detail")  # Route name, resolved via url_path_for()

Mounted Routes

For mounted routers with names:

app.mount("/admin", admin_router, name="admin")

# Access routes in the mounted router using colon syntax
app.url_path_for("admin:users")  # "/admin/users"
app.url_path_for("admin:user_detail", user_id=123)  # "/admin/users/123"

This namespacing prevents conflicts when multiple routers define routes with the same name. For example, both admin_router and public_router could have a route named "users", accessed as "admin:users" and "public:users" respectively.

Route Names

Route names enable URL generation and are automatically derived from the handler function name if not provided:

# Name automatically set to "get_user"
Query("/users/{user_id}", get_user)

# Explicit name
Query("/users/{user_id}", get_user, name="user_detail")

Best practices:

  • Use explicit names for important routes you'll reference
  • Use snake_case for consistency
  • Make names descriptive: user_detail, post_list, comment_create
  • Avoid generic names like index, detail, list in mounted routers

Next Steps