Skip to main content

Introduction

Routing in Framefox maps HTTP requests to controller methods using the @Route decorator. The routing system is built on top of FastAPI’s routing capabilities with additional features for automatic controller discovery and lazy loading.

The @Route Decorator

The @Route decorator is the primary way to define routes in Framefox:
/home/daytona/workspace/source/framefox/core/routing/decorator/route.py:15-22
class Route:
    def __init__(self, path: str, name: str, methods: list, response_model=None, tags=None):
        self.path = path
        self.name = name
        self.methods = methods
        self.response_model = response_model
        self.tags = tags or []

Basic Route

Define a simple route in your controller:
from framefox.core.controller.abstract_controller import AbstractController
from framefox.core.routing.decorator.route import Route

class HomeController(AbstractController):
    @Route("/", "home.index", methods=["GET"])
    async def index(self):
        return self.render("home/index.html", {"title": "Welcome"})

Route Parameters

  • path (str): The URL path pattern (e.g., "/users", "/posts/{id}")
  • name (str): Unique route name for URL generation (e.g., "user.list", "post.detail")
  • methods (list): HTTP methods (e.g., ["GET"], ["POST", "PUT"])
  • response_model (optional): Pydantic model for response validation
  • tags (optional): Tags for OpenAPI documentation grouping

Path Parameters

Define dynamic URL segments using curly braces:
class UserController(AbstractController):
    @Route("/users/{user_id}", "user.detail", methods=["GET"])
    async def detail(self, user_id: int):
        # user_id is automatically extracted from URL
        user = # fetch user by id
        return self.render("user/detail.html", {"user": user})

Multiple Path Parameters

@Route("/posts/{post_id}/comments/{comment_id}", "comment.detail", methods=["GET"])
async def comment_detail(self, post_id: int, comment_id: int):
    return self.json({
        "post_id": post_id,
        "comment_id": comment_id
    })

Type Conversion

Path parameters are automatically converted based on type hints:
# Integer parameter
@Route("/posts/{post_id}", "post.show", methods=["GET"])
async def show(self, post_id: int):
    # post_id is an int
    ...

# String parameter (default)
@Route("/users/{username}", "user.profile", methods=["GET"])
async def profile(self, username: str):
    # username is a str
    ...
The Route decorator automatically detects path parameters and ensures they’re passed correctly to your method.

HTTP Methods

Single Method Routes

@Route("/users", "user.list", methods=["GET"])
async def list(self):
    return self.render("user/list.html", {"users": users})

@Route("/users/create", "user.create", methods=["POST"])
async def create(self):
    # Handle POST request
    return self.redirect("/users")

Multiple Method Routes

A single method can handle multiple HTTP methods:
@Route("/users/{id}", "user.edit", methods=["GET", "POST"])
async def edit(self, id: int, request: Request):
    if request.method == "GET":
        # Show edit form
        return self.render("user/edit.html", {"user": user})
    else:
        # Handle form submission
        return self.redirect("/users")

REST-style Routes

class PostController(AbstractController):
    @Route("/api/posts", "api.post.list", methods=["GET"])
    async def list(self):
        return self.json({"posts": posts})
    
    @Route("/api/posts/{id}", "api.post.show", methods=["GET"])
    async def show(self, id: int):
        return self.json({"post": post})
    
    @Route("/api/posts", "api.post.create", methods=["POST"])
    async def create(self):
        return self.json({"post": new_post}, status=201)
    
    @Route("/api/posts/{id}", "api.post.update", methods=["PUT"])
    async def update(self, id: int):
        return self.json({"post": updated_post})
    
    @Route("/api/posts/{id}", "api.post.delete", methods=["DELETE"])
    async def delete(self, id: int):
        return self.json({"message": "Deleted"}, status=204)

Query Parameters

Query parameters are automatically extracted from the URL:
@Route("/search", "search.index", methods=["GET"])
async def search(self, q: str = "", page: int = 1, limit: int = 10):
    # Access query parameters: /search?q=python&page=2&limit=20
    results = # search with q, page, limit
    return self.render("search/results.html", {
        "results": results,
        "query": q,
        "page": page
    })
Query parameters with default values become optional. Without defaults, they’re required.

Request Body & Forms

JSON Request Body

Use Pydantic models for request validation:
from pydantic import BaseModel

class UserCreate(BaseModel):
    username: str
    email: str
    password: str

class UserController(AbstractController):
    @Route("/api/users", "api.user.create", methods=["POST"])
    async def create(self, user_data: UserCreate):
        # user_data is validated automatically
        return self.json({
            "username": user_data.username,
            "email": user_data.email
        })

Form Data

from fastapi import Form

@Route("/login", "auth.login", methods=["POST"])
async def login(self, username: str = Form(...), password: str = Form(...)):
    # Handle form submission
    return self.redirect("/dashboard")

URL Generation

Generate URLs from route names to avoid hardcoding:

In Controllers

Use the generate_url() method:
/home/daytona/workspace/source/framefox/core/controller/abstract_controller.py:53-65
def generate_url(self, route_name: str, **params):
    """
    Generates a URL for a named route with optional parameters.

    Args:
        route_name (str): The name of the route to generate URL for
        **params: Additional parameters to include in the URL

    Returns:
        str: The generated URL
    """
    router = self._get_container().get_by_name("Router")
    return router.url_path_for(route_name, **params)
Example:
@Route("/users/{id}/profile", "user.profile", methods=["GET"])
async def profile(self, id: int):
    edit_url = self.generate_url("user.edit", id=id)
    return self.render("user/profile.html", {
        "user": user,
        "edit_url": edit_url  # /users/123/edit
    })

In Templates

Generate URLs in Jinja2 templates:
{# Direct path #}
<a href="/users/123">User Profile</a>

{# Better: Use url_for (if available) #}
<a href="{{ url_for('user.profile', id=user.id) }}">User Profile</a>

Router Methods

Access the Router directly:
/home/daytona/workspace/source/framefox/core/routing/router.py:374-396
def url_path_for(self, name: str, **params) -> str:
    """Generate URL for a named route with parameters"""
    if name not in self._routes:
        self._logger.warning(f"Route '{name}' not found in registered routes")
        return "#"

    try:
        route_path = self._routes[name]
        for key, value in params.items():
            route_path = route_path.replace(f"{{{key}}}", str(value))
        return route_path
    except Exception as e:
        self._logger.error(f"Failed to generate URL for route '{name}': {e}")
        return "#"

def get_registered_routes(self) -> Dict[str, str]:
    """Get a copy of all registered routes"""
    return self._routes.copy()

def route_exists(self, name: str) -> bool:
    """Check if a route exists"""
    return name in self._routes

Automatic Dependency Injection

The Route decorator automatically injects services into controller methods:
/home/daytona/workspace/source/framefox/core/routing/decorator/route.py:28-58
@wraps(func)
async def wrapper(*args, **kwargs):
    controller_instance = args[0] if args else None

    if controller_instance and hasattr(controller_instance, "_container"):
        for param_name, param in original_sig.parameters.items():
            if param_name == "self" or param_name in kwargs:
                continue

            param_type = type_hints.get(param_name)

            if param_type and param_type != type(None):
                if (
                    self._is_fastapi_native_type(param_type)
                    or self._is_pydantic_model(param_type)
                    or self._is_primitive_type(param_type)
                    or self._is_path_parameter(param_name)
                ):
                    continue

                try:
                    service = controller_instance._container.get(param_type)
                    kwargs[param_name] = service
                except Exception as e:
                    if param.default != inspect.Parameter.empty:
                        kwargs[param_name] = param.default
                    else:
                        logger = logging.getLogger("ROUTE")
                        logger.error(f"Dependency injection failed for {param_type.__name__} in {func.__name__}.{param_name}: {e}")
                        raise RuntimeError(f"Dependency injection failed for {param_type.__name__}")

    return await func(*args, **kwargs)

Service Injection Example

from framefox.core.orm.entity_manager_interface import EntityManagerInterface
from framefox.core.logging.logger import Logger

class ProductController(AbstractController):
    @Route("/products", "product.list", methods=["GET"])
    async def list(self, em: EntityManagerInterface, logger: Logger):
        # Services automatically injected!
        logger.info("Fetching products")
        products = em.find_all(Product)
        return self.render("product/list.html", {"products": products})

Mixed Parameters

Combine path parameters, query parameters, request objects, and services:
from fastapi import Request

@Route("/posts/{post_id}/edit", "post.edit", methods=["GET", "POST"])
async def edit(
    self,
    post_id: int,              # Path parameter
    request: Request,          # FastAPI Request object
    em: EntityManagerInterface, # Injected service
    logger: Logger,            # Injected service
    preview: bool = False      # Query parameter
):
    logger.info(f"Editing post {post_id}")
    post = em.find(Post, post_id)
    
    if request.method == "POST":
        # Handle form submission
        return self.redirect(self.generate_url("post.show", id=post_id))
    
    return self.render("post/edit.html", {
        "post": post,
        "preview": preview
    })

Controller Discovery

Framefox automatically discovers controllers in the src/controller/ directory:
/home/daytona/workspace/source/framefox/core/routing/router.py:139-167
def _register_user_controllers(self):
    controllers_path = Path("src/controller")
    if not controllers_path.exists():
        self._logger.debug("No user controllers directory found")
        return

    registered_count = 0
    for controller_file in controllers_path.rglob("*.py"):
        if controller_file.name == "__init__.py":
            continue

        try:
            module_name = self._get_module_name(controller_file)
            controller_classes = self._discover_controller_classes(module_name)

            for controller_class in controller_classes:
                controller_name = controller_class.__name__.replace("Controller", "").lower()

                def create_lazy_factory(name: str):
                    return lambda: self.controller_resolver.resolve_controller(name)

                lazy_factory = create_lazy_factory(controller_name)
                self._register_controller_routes(controller_class, lazy_factory=lazy_factory)
                registered_count += 1
                self._logger.debug(f"Registered lazy controller: {controller_class.__name__}")

        except Exception as e:
            self._logger.error(f"Failed to register controller {controller_file}: {e}")
            continue

Controller Organization

src/controller/
├── home_controller.py
├── user_controller.py
├── post_controller.py
└── admin/
    ├── dashboard_controller.py
    └── settings_controller.py

Complete Example

Here’s a complete controller demonstrating various routing features:
/home/daytona/workspace/source/framefox/core/debug/profiler/profiler_controller.py:16-64
class ProfilerController(AbstractController):
    """
    Web profiler controller for Framefox.
    Handles request profiling information display.
    """

    def __init__(self):
        self.profiler = Profiler()

    @Route("/_profiler", "profiler.index", methods=["GET"])
    async def profiler_index(self, page: int = 1, limit: int = 50):
        profiles = self.profiler.list_profiles(limit=limit, page=page)
        total_count = len(self.profiler.list_profiles(limit=10000))

        return self.render(
            "profiler/index.html",
            {
                "profiles": profiles,
                "page": page,
                "limit": limit,
                "total": total_count,
                "page_count": (total_count + limit - 1) // limit,
            },
        )

    @Route("/_profiler/{token}", "profiler.detail", methods=["GET"])
    async def profiler_detail(self, token: str):
        profile = self.profiler.get_profile(token)
        return self.render("profiler/details.html", {"token": token, "profile": profile})

    @Route("/_profiler/{token}/{panel}", "profiler.panel", methods=["GET"])
    async def profiler_panel(self, token: str, panel: str):
        profile = self.profiler.get_profile(token)
        panel_data = profile.get(panel, {})

        template_context = {
            "token": token,
            "panel": panel,
            "data": panel_data,
            "profile": profile,
        }
        return self.render(f"profiler/panels/{panel}.html", template_context)

    @Route("/_profiler/{token}/json", "profiler.json", methods=["GET"])
    async def profiler_json(self, token: str):
        profile = self.profiler.get_profile(token)
        return JSONResponse(content=profile)

Best Practices

Choose descriptive route names that follow a consistent pattern:
# Good
@Route("/users", "user.list", methods=["GET"])
@Route("/users/{id}", "user.detail", methods=["GET"])
@Route("/users/create", "user.create", methods=["POST"])

# Bad
@Route("/users", "route1", methods=["GET"])
@Route("/users/{id}", "show_user", methods=["GET"])
@Route("/users/create", "user_creation", methods=["POST"])
Follow REST conventions for API endpoints:
GET    /api/posts          # List
GET    /api/posts/{id}     # Show
POST   /api/posts          # Create
PUT    /api/posts/{id}     # Update
DELETE /api/posts/{id}     # Delete
Always use type hints for path parameters and dependency injection:
# Good
@Route("/posts/{id}", "post.show", methods=["GET"])
async def show(self, id: int, em: EntityManagerInterface):
    ...

# Bad - no type information
@Route("/posts/{id}", "post.show", methods=["GET"])
async def show(self, id, em):
    ...
Never hardcode URLs - use route names:
# Good
redirect_url = self.generate_url("user.profile", id=user.id)

# Bad
redirect_url = f"/users/{user.id}/profile"
Use Pydantic models for request validation:
from pydantic import BaseModel, validator

class UserCreate(BaseModel):
    username: str
    email: str
    
    @validator('email')
    def email_must_be_valid(cls, v):
        if '@' not in v:
            raise ValueError('Invalid email')
        return v

@Route("/api/users", "api.user.create", methods=["POST"])
async def create(self, data: UserCreate):
    # data is validated automatically
    ...

OpenAPI Documentation

Framefox automatically generates OpenAPI documentation for your routes:
@Route(
    "/api/users",
    "api.user.list",
    methods=["GET"],
    response_model=UserListResponse,  # Pydantic model for response
    tags=["Users"]  # Groups in OpenAPI docs
)
async def list_users(self):
    ...
Access the interactive documentation at:
  • Swagger UI: http://localhost:8000/docs
  • ReDoc: http://localhost:8000/redoc
OpenAPI documentation is only available in development mode (app_env=dev).

Next Steps

MVC Pattern

Learn about controllers and views

Dependency Injection

Understand service injection in routes

Request Handling

Work with requests and responses

API Development

Build RESTful APIs with Framefox

Build docs developers (and LLMs) love