Projeto final: API REST completa com testes e documentação

1. Planejamento e Estrutura do Projeto

Uma API REST bem construída começa com planejamento sólido. Neste projeto final, desenvolveremos uma API de gerenciamento de tarefas (To-Do List) com operações CRUD completas, autenticação JWT e documentação automática.

Requisitos funcionais:
- Cadastro e autenticação de usuários
- Criação, listagem, atualização e exclusão de tarefas
- Controle por roles (admin e usuário comum)
- Paginação e filtros

Escolha do framework: Optamos pelo FastAPI por sua performance, validação nativa com Pydantic e geração automática de documentação OpenAPI.

Estrutura de diretórios:

todo_api/
├── app/
│   ├── main.py
│   ├── config.py
│   ├── models/
│   ├── schemas/
│   ├── routes/
│   ├── services/
│   ├── dependencies.py
│   └── database.py
├── tests/
├── migrations/
├── Dockerfile
├── docker-compose.yml
└── requirements.txt

2. Modelagem de Dados e Configuração do Banco

Utilizamos SQLAlchemy como ORM e Pydantic para schemas de validação.

# app/config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    DATABASE_URL: str = "sqlite:///./todo.db"
    SECRET_KEY: str = "sua-chave-secreta-aqui"
    ALGORITHM: str = "HS256"
    ACCESS_TOKEN_EXPIRE_MINUTES: int = 30

    class Config:
        env_file = ".env"

settings = Settings()
# app/models.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True)
    email = Column(String, unique=True, index=True)
    hashed_password = Column(String)
    is_active = Column(Boolean, default=True)
    role = Column(String, default="user")
    created_at = Column(DateTime, default=datetime.utcnow)

    tasks = relationship("Task", back_populates="owner")

class Task(Base):
    __tablename__ = "tasks"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String, index=True)
    description = Column(String)
    completed = Column(Boolean, default=False)
    created_at = Column(DateTime, default=datetime.utcnow)
    owner_id = Column(Integer, ForeignKey("users.id"))

    owner = relationship("User", back_populates="tasks")

3. Implementação dos Endpoints da API REST

Criamos schemas Pydantic para validação de entrada/saída e rotas CRUD completas.

# app/schemas.py
from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import Optional

class TaskBase(BaseModel):
    title: str
    description: Optional[str] = None
    completed: bool = False

class TaskCreate(TaskBase):
    pass

class TaskResponse(TaskBase):
    id: int
    created_at: datetime
    owner_id: int

    class Config:
        from_attributes = True

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

class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    role: str
    is_active: bool

    class Config:
        from_attributes = True
# app/routes/tasks.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app import schemas, models, dependencies

router = APIRouter(prefix="/tasks", tags=["tasks"])

@router.get("/", response_model=List[schemas.TaskResponse])
def list_tasks(
    skip: int = 0,
    limit: int = 100,
    db: Session = Depends(dependencies.get_db),
    current_user: models.User = Depends(dependencies.get_current_user)
):
    tasks = db.query(models.Task).filter(
        models.Task.owner_id == current_user.id
    ).offset(skip).limit(limit).all()
    return tasks

@router.post("/", response_model=schemas.TaskResponse, status_code=status.HTTP_201_CREATED)
def create_task(
    task: schemas.TaskCreate,
    db: Session = Depends(dependencies.get_db),
    current_user: models.User = Depends(dependencies.get_current_user)
):
    db_task = models.Task(**task.model_dump(), owner_id=current_user.id)
    db.add(db_task)
    db.commit()
    db.refresh(db_task)
    return db_task

@router.put("/{task_id}", response_model=schemas.TaskResponse)
def update_task(
    task_id: int,
    task: schemas.TaskCreate,
    db: Session = Depends(dependencies.get_db),
    current_user: models.User = Depends(dependencies.get_current_user)
):
    db_task = db.query(models.Task).filter(
        models.Task.id == task_id,
        models.Task.owner_id == current_user.id
    ).first()
    if not db_task:
        raise HTTPException(status_code=404, detail="Task not found")

    for key, value in task.model_dump().items():
        setattr(db_task, key, value)

    db.commit()
    db.refresh(db_task)
    return db_task

@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(
    task_id: int,
    db: Session = Depends(dependencies.get_db),
    current_user: models.User = Depends(dependencies.get_current_user)
):
    db_task = db.query(models.Task).filter(
        models.Task.id == task_id,
        models.Task.owner_id == current_user.id
    ).first()
    if not db_task:
        raise HTTPException(status_code=404, detail="Task not found")

    db.delete(db_task)
    db.commit()

4. Autenticação e Autorização

Implementamos autenticação JWT com controle de acesso por roles.

# app/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from typing import Optional
from app import models, schemas
from app.config import settings

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
    return pwd_context.hash(password)

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)

def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: Session = Depends(get_db)
) -> models.User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception

    user = db.query(models.User).filter(models.User.username == username).first()
    if user is None:
        raise credentials_exception
    return user

def require_admin(current_user: models.User = Depends(get_current_user)):
    if current_user.role != "admin":
        raise HTTPException(status_code=403, detail="Admin privileges required")
    return current_user

5. Testes Automatizados

Testes com pytest cobrindo cenários de sucesso, erro e borda.

# tests/test_tasks.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.database import Base, get_db
from app.dependencies import get_password_hash

SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

def override_get_db():
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()

app.dependency_overrides[get_db] = override_get_db

@pytest.fixture
def client():
    Base.metadata.create_all(bind=engine)
    yield TestClient(app)
    Base.metadata.drop_all(bind=engine)

def test_create_task(client):
    # Primeiro cria um usuário e obtém token
    client.post("/auth/register", json={
        "username": "testuser",
        "email": "test@example.com",
        "password": "testpass123"
    })
    response = client.post("/auth/login", data={
        "username": "testuser",
        "password": "testpass123"
    })
    token = response.json()["access_token"]

    headers = {"Authorization": f"Bearer {token}"}
    response = client.post("/tasks/", json={
        "title": "Minha tarefa",
        "description": "Descrição da tarefa"
    }, headers=headers)

    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "Minha tarefa"
    assert data["completed"] == False

def test_list_tasks_empty(client):
    client.post("/auth/register", json={
        "username": "testuser2",
        "email": "test2@example.com",
        "password": "testpass123"
    })
    response = client.post("/auth/login", data={
        "username": "testuser2",
        "password": "testpass123"
    })
    token = response.json()["access_token"]

    headers = {"Authorization": f"Bearer {token}"}
    response = client.get("/tasks/", headers=headers)

    assert response.status_code == 200
    assert response.json() == []

def test_delete_nonexistent_task(client):
    client.post("/auth/register", json={
        "username": "testuser3",
        "email": "test3@example.com",
        "password": "testpass123"
    })
    response = client.post("/auth/login", data={
        "username": "testuser3",
        "password": "testpass123"
    })
    token = response.json()["access_token"]

    headers = {"Authorization": f"Bearer {token}"}
    response = client.delete("/tasks/999", headers=headers)

    assert response.status_code == 404

6. Documentação da API

O FastAPI gera automaticamente documentação OpenAPI/Swagger. Customizamos com tags e exemplos.

# app/main.py
from fastapi import FastAPI
from app.routes import auth, tasks, admin

app = FastAPI(
    title="Todo API",
    description="API REST completa para gerenciamento de tarefas",
    version="1.0.0",
    contact={
        "name": "Seu Nome",
        "email": "seu@email.com"
    },
    license_info={
        "name": "MIT",
        "url": "https://opensource.org/licenses/MIT"
    }
)

app.include_router(auth.router, prefix="/auth", tags=["autenticação"])
app.include_router(tasks.router, prefix="/tasks", tags=["tarefas"])
app.include_router(admin.router, prefix="/admin", tags=["administração"])

Acesse http://localhost:8000/docs para visualizar a documentação interativa.

7. Empacotamento e Deploy

Conteinerização com Docker para deploy consistente.

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# docker-compose.yml
version: '3.8'

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://user:password@db:5432/todo_db
      - SECRET_KEY=${SECRET_KEY}
    depends_on:
      - db
    volumes:
      - .:/app

  db:
    image: postgres:15
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=todo_db
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

8. Validação Final e Boas Práticas

Checklist de qualidade:
- [ ] Testes passando com cobertura > 80%
- [ ] Documentação Swagger acessível
- [ ] Linters configurados (flake8, black, mypy)
- [ ] Variáveis sensíveis em variáveis de ambiente
- [ ] Docker build funcional
- [ ] Tratamento de erros consistente

Comandos úteis:

# Executar testes
pytest --cov=app tests/

# Verificar estilo de código
black --check app/
flake8 app/
mypy app/

# Build Docker
docker-compose build
docker-compose up -d

Referências