Routing Guide 🗺️

This guide covers everything you need to know about routing in Tatami. You’ll learn about URL patterns, parameters, HTTP methods, and advanced routing techniques.

Why Class-Based Routers? 🏗️

Tatami uses class-based routers because they provide:

🎯 Organization: Related endpoints stay together 🔧 Reusability: Share logic between endpoints 🧹 Clean Code: Clear separation of concerns 💉 Dependency Injection: Easy service integration 📚 Documentation: Self-documenting API structure

Traditional frameworks scatter routes across files. Tatami groups them logically:

# ✅ Tatami way - organized and clear
class Users(router('/users')):
    @get('/{user_id}')
    def get_user(self, user_id: int):
        return self.user_service.get(user_id)

    @post('/')
    def create_user(self, user: User):
        return self.user_service.create(user)

# ❌ Traditional way - scattered routes
@app.get('/users/{user_id}')
def get_user(user_id: int):
    # Logic here...

@app.post('/users')
def create_user(user: User):
    # Logic here...

Basic Router Setup

Creating a Router

Use the router() function to create a router class:

from tatami import router, get, post, put, delete

class Products(router('/products')):
    """Product management endpoints"""
    pass

The path /products becomes the base URL for all endpoints in this router.

Adding Endpoints

Use HTTP method decorators to define endpoints:

class Products(router('/products')):
    @get
    def list_products(self):
        """GET /products - List all products"""
        return {"products": []}

    @get('/{product_id}')
    def get_product(self, product_id: int):
        """GET /products/{product_id} - Get specific product"""
        return {"id": product_id}

    @post
    def create_product(self, product: ProductCreate):
        """POST /products - Create new product"""
        return {"message": "Product created"}

HTTP Method Decorators

Tatami provides decorators for all HTTP methods:

from tatami import get, post, put, patch, delete, head, options

class API(router('/api')):
    @get('/data')
    def get_data(self):
        return {"method": "GET"}

    @post('/data')
    def create_data(self, data: dict):
        return {"method": "POST", "data": data}

    @put('/data/{id}')
    def replace_data(self, id: int, data: dict):
        return {"method": "PUT", "id": id}

    @patch('/data/{id}')
    def update_data(self, id: int, updates: dict):
        return {"method": "PATCH", "id": id}

    @delete('/data/{id}')
    def delete_data(self, id: int):
        return {"method": "DELETE", "id": id}

    @head('/data')
    def head_data(self):
        # Return headers only, no body
        pass

    @options('/data')
    def options_data(self):
        return {"allowed_methods": ["GET", "POST", "PUT", "DELETE"]}

URL Parameters

Path Parameters

Extract values from the URL path:

class Users(router('/users')):
    @get('/{user_id}')
    def get_user(self, user_id: int):
        """user_id comes from the URL path"""
        return {"user_id": user_id}

    @get('/{user_id}/posts/{post_id}')
    def get_user_post(self, user_id: int, post_id: int):
        """Multiple path parameters"""
        return {"user_id": user_id, "post_id": post_id}

Path parameters are automatically converted to the specified type (int, str, float, etc.).

Query Parameters

Extract values from the query string:

from tatami.param import Query

class Products(router('/products')):
    @get
    def list_products(
        self,
        page: int = Query(1),
        limit: int = Query(10),
        category: str = Query(None)
    ):
        """
        GET /products?page=2&limit=20&category=electronics
        """
        return {
            "page": page,
            "limit": limit,
            "category": category
        }

Required vs Optional Parameters

from tatami.param import Query

class Search(router('/search')):
    @get
    def search(
        self,
        q: str = Query(...),  # Required
        type: str = Query("all"),  # Optional with default
        limit: int = Query(None)  # Optional, can be None
    ):
        return {"query": q, "type": type, "limit": limit}

Header Parameters

Extract values from HTTP headers:

from tatami.param import Header

class API(router('/api')):
    @get('/data')
    def get_data(
        self,
        authorization: str = Header(...),
        content_type: str = Header("application/json", alias="Content-Type"),
        user_agent: str = Header(None, alias="User-Agent")
    ):
        return {
            "auth": authorization,
            "content_type": content_type,
            "user_agent": user_agent
        }

Request Body Handling

JSON Request Bodies

Use Pydantic models for request body validation:

from pydantic import BaseModel, Field

class ProductCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(gt=0)
    description: str = Field(None, max_length=500)
    tags: List[str] = Field(default_factory=list)

class Products(router('/products')):
    @post('/')
    def create_product(self, product: ProductCreate):
        """Automatic JSON parsing and validation"""
        return {
            "message": f"Created product: {product.name}",
            "price": product.price
        }

Form Data

Handle form submissions:

from starlette.requests import Request

class Upload(router('/upload')):
    @post('/file')
    async def upload_file(self, request: Request):
        """Handle file uploads"""
        form = await request.form()
        file = form.get("file")

        if file:
            content = await file.read()
            return {"filename": file.filename, "size": len(content)}

        return {"error": "No file provided"}, 400

Advanced Routing Patterns

Nested Routers

Create hierarchical URL structures:

class Users(router('/users')):
    @get('/{user_id}')
    def get_user(self, user_id: int):
        return {"user_id": user_id}

class UserPosts(router('/users/{user_id}/posts')):
    @get
    def list_user_posts(self, user_id: int):
        return {"user_id": user_id, "posts": []}

    @get('/{post_id}')
    def get_user_post(self, user_id: int, post_id: int):
        return {"user_id": user_id, "post_id": post_id}

Router Composition

Include routers within other routers:

from tatami import BaseRouter

# Create sub-routers
users_router = Users()
posts_router = Posts()

# Main application
app = BaseRouter(title="My API")
app.include_router(users_router)
app.include_router(posts_router)

Custom Response Types

Return different response types:

from starlette.responses import JSONResponse, HTMLResponse, RedirectResponse

class Pages(router('/pages')):
    @get('/json')
    def json_response(self):
        return JSONResponse({"message": "JSON response"})

    @get('/html')
    def html_response(self):
        return HTMLResponse("<h1>HTML Response</h1>")

    @get('/redirect')
    def redirect_response(self):
        return RedirectResponse(url="/pages/html")

Error Handling

Return Error Responses

Return tuples for error responses:

class Users(router('/users')):
    @get('/{user_id}')
    def get_user(self, user_id: int):
        if user_id < 1:
            return {"error": "Invalid user ID"}, 400

        user = self.user_service.get(user_id)
        if not user:
            return {"error": "User not found"}, 404

        return user

Custom Exceptions

Create and raise custom exceptions:

class UserNotFoundError(Exception):
    pass

class Users(router('/users')):
    @get('/{user_id}')
    def get_user(self, user_id: int):
        user = self.user_service.get(user_id)
        if not user:
            raise UserNotFoundError(f"User {user_id} not found")
        return user

Route Priority and Ordering

Tatami automatically orders routes by specificity:

class API(router('/api')):
    @get('/users/me')          # 🥇 Most specific - matches first
    def get_current_user(self):
        return {"user": "current"}

    @get('/users/{user_id}')   # 🥈 Less specific - matches after /me
    def get_user(self, user_id: int):
        return {"user_id": user_id}

    @get('/users')             # 🥉 Least specific - matches last
    def list_users(self):
        return {"users": []}

The order in your code doesn’t matter - Tatami sorts routes intelligently!

Middleware Integration

Add middleware to specific routers:

from starlette.middleware.base import BaseHTTPMiddleware

class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        # Authentication logic
        response = await call_next(request)
        return response

class AdminAPI(router('/admin')):
    def __init__(self):
        super().__init__()
        self.add_middleware(AuthMiddleware)

    @get('/dashboard')
    def dashboard(self):
        return {"page": "admin dashboard"}

Dependency Injection in Routers

Inject services into router constructors:

class Users(router('/users')):
    def __init__(self, user_service: UserService, email_service: EmailService):
        super().__init__()
        self.user_service = user_service
        self.email_service = email_service

    @post('/')
    def create_user(self, user: UserCreate):
        # Use injected services
        new_user = self.user_service.create(user)
        self.email_service.send_welcome(new_user.email)
        return new_user

Testing Routers

Test routers using standard HTTP clients:

import httpx
from tatami import BaseRouter

def test_users_api():
    # Setup
    users_router = Users(user_service=MockUserService())
    app = BaseRouter()
    app.include_router(users_router)

    # Test
    with httpx.Client(app=app, base_url="http://test") as client:
        response = client.get("/users/1")
        assert response.status_code == 200
        assert response.json()["user_id"] == 1

Best Practices

🎯 Keep Routers Focused

Each router should handle one resource or domain:

# ✅ Good - focused on users
class Users(router('/users')):
    @get
    def list_users(self): pass

    @post
    def create_user(self): pass

# ❌ Bad - mixed concerns
class MixedAPI(router('/api')):
    @get('/users')
    def list_users(self): pass

    @get('/products')
    def list_products(self): pass

🔧 Use Type Hints

Always use type hints for better IDE support and validation:

class Products(router('/products')):
    @get('/{product_id}')
    def get_product(self, product_id: int) -> ProductResponse:
        return self.product_service.get(product_id)

📋 Document Your Endpoints

Use docstrings for API documentation:

class Users(router('/users')):
    @get('/{user_id}')
    def get_user(self, user_id: int):
        """
        Get a user by ID.

        Returns user information including name, email, and profile data.
        """
        return self.user_service.get(user_id)

🔍 Validate Input Early

Use Pydantic models for comprehensive validation:

class UserCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    email: EmailStr
    age: int = Field(ge=13, le=120)

class Users(router('/users')):
    @post('/')
    def create_user(self, user: UserCreate):
        # Validation happens automatically!
        return self.user_service.create(user)

Common Patterns

RESTful CRUD Operations

Standard REST API pattern:

class Products(router('/products')):
    @get
    def list_products(self):
        """GET /products - List all products"""
        return self.product_service.list()

    @get('/{product_id}')
    def get_product(self, product_id: int):
        """GET /products/{id} - Get specific product"""
        return self.product_service.get(product_id)

    @post('/')
    def create_product(self, product: ProductCreate):
        """POST /products - Create new product"""
        return self.product_service.create(product)

    @put('/{product_id}')
    def update_product(self, product_id: int, product: ProductUpdate):
        """PUT /products/{id} - Update product"""
        return self.product_service.update(product_id, product)

    @delete('/{product_id}')
    def delete_product(self, product_id: int):
        """DELETE /products/{id} - Delete product"""
        self.product_service.delete(product_id)
        return {"message": "Product deleted"}

Search and Filtering

Implement search with query parameters:

class Products(router('/products')):
    @get('/search')
    def search_products(
        self,
        q: str = Query(...),
        category: str = Query(None),
        min_price: float = Query(None),
        max_price: float = Query(None),
        sort: str = Query("name"),
        page: int = Query(1),
        limit: int = Query(20)
    ):
        """Search products with filters"""
        return self.product_service.search(
            query=q,
            category=category,
            min_price=min_price,
            max_price=max_price,
            sort=sort,
            page=page,
            limit=limit
        )

Batch Operations

Handle multiple items at once:

class Products(router('/products')):
    @post('/batch')
    def create_products_batch(self, products: List[ProductCreate]):
        """Create multiple products at once"""
        results = []
        for product in products:
            try:
                result = self.product_service.create(product)
                results.append({"success": True, "product": result})
            except Exception as e:
                results.append({"success": False, "error": str(e)})
        return {"results": results}

What’s Next?

Now you’re a Tatami routing expert! Next, learn about:

  • Working with data and database integration

  • Dependency injection for better code organization

  • Middleware for request processing

  • Testing strategies for your APIs

Happy routing! 🎯