Saltar a contenido

Añade un endpoint de la API

Este ejemplo añade un endpoint de "Notification" de principio a fin, siguiendo la estratificación que usa aquí cada dominio.

Rutas → servicios → repositorios, y nunca un atajo

Una ruta valida, delega y devuelve. Una ruta nunca importa un repositorio, y un repositorio nunca contiene lógica de negocio. Consulta Arquitectura.

Paso a paso

1. Crea el schema (app/schemas/)

# app/schemas/notification.py
from datetime import datetime
from uuid import UUID

from pydantic import Field

from app.schemas.base import BaseSchema


class NotificationCreate(BaseSchema):
    title: str = Field(max_length=255)
    body: str
    channel: str = "email"


class NotificationRead(BaseSchema):
    id: UUID
    title: str
    body: str
    channel: str
    is_read: bool
    created_at: datetime


class NotificationList(BaseSchema):
    items: list[NotificationRead]
    total: int

Un schema por operación — *Create, *Update (con todos los campos opcionales), *Read (con id y marcas de tiempo) y *List (items más total). La regla está en Schemas y modelos.

2. Crea el modelo de base de datos (app/db/models/)

# app/db/models/notification.py
import uuid

from sqlalchemy import Boolean, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column

from app.db.base import Base, TimestampMixin


class Notification(Base, TimestampMixin):
    """One notification sent to somebody, and whether they have read it."""

    __tablename__ = "notifications"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    body: Mapped[str] = mapped_column(String, nullable=False)
    channel: Mapped[str] = mapped_column(String(50), default="email", nullable=False)
    is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

    def __repr__(self) -> str:
        return f"<Notification(id={self.id}, title={self.title})>"

TimestampMixin aporta created_at y updated_at, así que aquí no se declara ninguno de los dos. __repr__ no es opcional — todos los modelos de esta base de código tienen uno. Importa el modelo en app/db/models/__init__.py o Alembic no lo verá.

3. Crea el repositorio (app/repositories/)

# app/repositories/notification.py
from uuid import UUID

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.models.notification import Notification


async def get_by_id(db: AsyncSession, notification_id: UUID) -> Notification | None:
    return await db.get(Notification, notification_id)


async def create(db: AsyncSession, *, title: str, body: str, channel: str) -> Notification:
    notification = Notification(title=title, body=body, channel=channel)
    db.add(notification)
    await db.flush()
    await db.refresh(notification)
    return notification


async def list_unread(db: AsyncSession, limit: int = 50) -> list[Notification]:
    result = await db.execute(
        select(Notification)
        .where(Notification.is_read.is_(False))
        .order_by(Notification.created_at.desc())
        .limit(limit)
    )
    return list(result.scalars().all())


async def count_unread(db: AsyncSession) -> int:
    result = await db.execute(
        select(func.count()).select_from(Notification).where(Notification.is_read.is_(False))
    )
    return result.scalar_one()

Una lista paginada necesita un recuento de verdad, no len(items)

El total de un *List es cuántas filas coinciden, que es contra lo que pagina un cliente. len(items) es cuántas han vuelto — iguales solo hasta que se llena la primera página, y a partir de ahí silenciosamente equivocado en la dirección que oculta filas.

flush() + refresh(), nunca commit()

La sesión de la petición hace commit una sola vez, después de que la ruta devuelva y antes de que se escriba la respuesta — que es lo que hace que un 2xx signifique que lo escrito se puede leer (#353). Un repositorio que hace commit le quita ese orden al único sitio al que pertenece.

Un repositorio es un módulo de funciones sin estado, no una clase: db primero, todo lo demás solo por palabra clave, y la entidad devuelta en lugar de un id o un dict. Vuelve a exportarlo desde app/repositories/__init__.py igual que todos los demás — from app.repositories import notification as notification_repo — para que quien lo llame importe el alias y no la ruta del módulo.

4. Crea el servicio (app/services/)

# app/services/notification.py
from uuid import UUID

from sqlalchemy.ext.asyncio import AsyncSession

from app.core.exceptions import NotFoundError
from app.db.models.notification import Notification
from app.repositories import notification_repo
from app.schemas.notification import NotificationCreate


class NotificationService:
    def __init__(self, db: AsyncSession):
        self.db = db

    async def create(self, data: NotificationCreate) -> Notification:
        return await notification_repo.create(
            self.db, title=data.title, body=data.body, channel=data.channel
        )

    async def get_or_raise(self, notification_id: UUID) -> Notification:
        notification = await notification_repo.get_by_id(self.db, notification_id)
        if not notification:
            raise NotFoundError(
                message="Notification not found",
                details={"notification_id": notification_id},
            )
        return notification

    async def list_unread(self) -> tuple[list[Notification], int]:
        items = await notification_repo.list_unread(self.db)
        return items, await notification_repo.count_unread(self.db)

El servicio guarda la sesión y nada más; los repositorios se importan como módulos. Es además la única capa que lanza una excepción de dominio, y details lleva el valor en lugar de una cadena con él — el handler lo codifica con jsonable_encoder.

5. Registra la dependencia (app/api/deps.py)

from app.services.notification import NotificationService


def get_notification_service(db: DBSession) -> NotificationService:
    return NotificationService(db)


NotificationSvc = Annotated[NotificationService, Depends(get_notification_service)]

6. Crea la ruta (app/api/routes/v1/)

# app/api/routes/v1/notifications.py
from typing import Any

from fastapi import APIRouter, status

from app.api.deps import CurrentUser, NotificationSvc
from app.schemas.notification import NotificationCreate, NotificationList, NotificationRead

router = APIRouter()


@router.post("", response_model=NotificationRead, status_code=status.HTTP_201_CREATED)
async def create_notification(
    data: NotificationCreate, service: NotificationSvc, user: CurrentUser
) -> Any:
    return await service.create(data)


@router.get("", response_model=NotificationList)
async def list_unread(service: NotificationSvc, user: CurrentUser) -> Any:
    items, total = await service.list_unread()
    return NotificationList(items=items, total=total)

-> Any, por convención aquí

response_model es lo que serializa y valida la respuesta, y todas las rutas de esta base de código dejan la anotación en Any para que haya una sola respuesta a "dónde se declara la forma de la respuesta" en vez de dos que puedan contradecirse. Síguelo por coherencia con el código que lo rodea — no porque una anotación fuese a costar una segunda pasada de validación, que no lo hace.

Todo lo que está acotado a una organización toma un permiso del catálogo en la ruta de coleccióndependencies=[Depends(require(Perm.X))] —, mientras que una ruta por recurso entrega la decisión a un servicio que llama a resolve_access. Consulta Permisos.

7. Registra el router

En app/api/routes/v1/__init__.py:

from app.api.routes.v1 import notifications

v1_router.include_router(
    notifications.router, prefix="/notifications", tags=["notifications"]
)

8. Crea y aplica la migración

make db-migrate    # message: "Add notifications table"
make db-upgrade
make db-check      # a model change with no migration fails here, and in `make check`

9. Pruébala

Una ruta se entrega con tests, no después de ellos: uno que compruebe que su puerta está conectada (tests/api/), uno por rama del servicio incluido el rechazo, y un test de integración si lo que has cambiado de verdad es una restricción o una cascada. Después, http://localhost:8000/docs para probarla a mano.