Skip to content

API Development

Learn how to build REST APIs with the FastAPI Boilerplate. This section covers everything you need to create robust, production-ready APIs.

What You'll Learn

  • Endpoints - Create endpoints with authentication and validation
  • Pagination - Add pagination to list endpoints
  • Exception Handling - Handle errors with the boilerplate's exception types
  • API Versioning - Version your APIs and maintain backward compatibility

Quick Overview

Routes are defined in each module's routes.py. The aggregator at interfaces/api/v1/__init__.py mounts each module's router under /api/v1.

# backend/src/modules/user/routes.py
from typing import Annotated, Any
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from ...infrastructure.database.session import async_session
from .schemas import UserCreate, UserRead
from .service import UserService

router = APIRouter(tags=["Users"])


def get_user_service() -> UserService:
    return UserService()


@router.post("/", response_model=UserRead, status_code=201)
async def create_user(
    user: UserCreate,
    db: Annotated[AsyncSession, Depends(async_session)],
    user_service: Annotated[UserService, Depends(get_user_service)],
) -> dict[str, Any]:
    return await user_service.create(user, db)

The aggregator wires it up:

# backend/src/interfaces/api/v1/__init__.py
from fastapi import APIRouter

from ....modules.user.routes import router as users_router

router = APIRouter(prefix="/v1")
router.include_router(users_router, prefix="/users")

Final URL: POST /api/v1/users/.

Key Features

Built-in Authentication

Session-based auth with HTTP-only cookies. Pull the current user from infrastructure/auth/dependencies:

from ...infrastructure.auth.dependencies import get_current_user

@router.get("/me", response_model=UserRead)
async def get_profile(
    current_user: Annotated[dict[str, Any], Depends(get_current_user)],
) -> dict[str, Any]:
    return current_user

For superuser-only endpoints, swap in get_current_superuser. For endpoints gated on a permission a role grants, add require_permissions(...) to the route's dependencies:

from ...infrastructure.auth.authorization import require_permissions

@router.get("/", dependencies=[require_permissions("user.read")])
async def list_users(...): ...

infrastructure/auth/deps.py also exports CurrentPrincipalDep (the crudauth Principal) and CurrentPermissionsDep (the caller's effective permissions) for handlers that need them. See Authentication for the full picture.

Easy Pagination

The boilerplate uses FastCRUD's PaginatedListResponse and paginated_response() helper:

from fastcrud import PaginatedListResponse, compute_offset, paginated_response

@router.get("/", response_model=PaginatedListResponse[UserRead])
async def list_users(
    db: Annotated[AsyncSession, Depends(async_session)],
    user_service: Annotated[UserService, Depends(get_user_service)],
    page: int = 1,
    items_per_page: int = 10,
) -> dict[str, Any]:
    result = await user_service.get_paginated(
        skip=compute_offset(page, items_per_page),
        limit=items_per_page,
        db=db,
    )
    return paginated_response(crud_data=result, page=page, items_per_page=items_per_page)

Automatic Validation

Request bodies, query parameters, and response models are all validated by Pydantic:

@router.post("/", response_model=UserRead)
async def create_user(user: UserCreate):  # ← validates input
    return await user_service.create(user, db)  # ← validates output via response_model

Error Handling

Domain errors live in modules/common/exceptions.py, and each feature subclasses them. The app installs one handler for them, so a route raises (or lets the service raise) and the client gets the mapped status with a support_id:

from ..common.exceptions import ResourceNotFoundError


@router.get("/{username}", response_model=UserRead)
async def get_user_by_username(
    username: str,
    db: Annotated[AsyncSession, Depends(async_session)],
    user_service: Annotated[UserService, Depends(get_user_service)],
) -> dict[str, Any]:
    user = await user_service.get_by_username(username, db)
    if user is None:
        raise ResourceNotFoundError(f"user {username}")

    return user

See Exception Handling for the full catalog.

Architecture

HTTP Request
    ↓
APIRouter         (modules/<feature>/routes.py)
    ↓
Service           (modules/<feature>/service.py)  — business rules, permission checks
    ↓
FastCRUD          (modules/<feature>/crud.py)
    ↓
SQLAlchemy Model  (modules/<feature>/models.py)
    ↓
PostgreSQL

The split keeps:

  • HTTP concerns (status codes, schemas, dependencies) in routes.py
  • Business logic (validation, orchestration) in service.py
  • Database I/O in crud.py

You can mock any layer in tests; you can change one without breaking the others.

Directory Structure

backend/src/
├── interfaces/
│   └── api/
│       ├── __init__.py            # mounts /api
│       └── v1/
│           └── __init__.py        # mounts /v1 + every module's router
├── infrastructure/
│   └── auth/
│       └── routes.py              # /api/v1/auth/* (login, OAuth, check-auth)
└── modules/
    ├── user/routes.py             # /api/v1/users/*
    ├── tier/routes.py             # /api/v1/tiers/*
    ├── rate_limit/routes.py       # /api/v1/rate-limits/*
    └── api_keys/routes.py         # /api/v1/api-keys/*

Auth lives in infrastructure/auth/routes.py instead of in a feature module because authentication is structural — every other feature depends on it.

Mounted Endpoints

What ships out of the box:

Prefix Source Notes
POST/GET/PATCH/DELETE /api/v1/users/* modules/user/routes.py Open create; reads/updates need a session, and a lookup by username returns no email. Listing every user needs the user.read permission
GET /api/v1/tiers/* modules/tier/routes.py Authenticated list + lookup by name
GET/PATCH/DELETE /api/v1/rate-limits/* modules/rate_limit/routes.py Superuser only
POST /api/v1/auth/login, logout, check-auth infrastructure/auth/routes.py Session auth
GET /api/v1/auth/sessions, DELETE /api/v1/auth/sessions/{handle}, POST /api/v1/auth/logout-all, POST /api/v1/auth/csrf/refresh crudauth, mounted in infrastructure/auth/routes.py Device management
POST /api/v1/auth/change-password, GET /api/v1/auth/me crudauth's account router, mounted in infrastructure/auth/routes.py Session auth; set-password is not mounted
GET /api/v1/auth/oauth/{provider}, oauth/callback/{provider} crudauth router mounted in infrastructure/auth/routes.py Google OAuth (configured in infrastructure/auth/setup.py)
POST/GET/PATCH/DELETE /api/v1/api-keys/* modules/api_keys/routes.py Authenticated key management
GET /admin/* interfaces/admin/initialize.py SQLAdmin UI
GET /docs, /redoc, /openapi.json App factory (protected when gated) Disabled in production unless ENABLE_DOCS_IN_PRODUCTION=true; when enabled in production or running in staging, requires superuser authentication
GET /health interfaces/main.py Liveness: the process is serving
GET /health/ready interfaces/main.py Readiness: the overall status, 503 while a critical dependency is unreachable

What's Next

Start with the basics:

  1. Endpoints - Common patterns for new routes
  2. Pagination - List endpoints with paged responses
  3. Exception Handling - The boilerplate's exception model
  4. API Versioning - Versioning strategy

Then go deeper:

  1. Database Schemas - Pydantic shapes used in routes
  2. CRUD Operations - The data layer below the service