Chegamos ao módulo final — Projetos Reais. Aqui consolidamos tudo que aprendemos nos módulos anteriores em sistemas completos e funcionais. O Projeto 1 é uma API REST de gestão escolar construída com FastAPI, PostgreSQL e Docker — seguindo boas práticas de arquitetura, testes e deploy. É o tipo de projeto que você colocaria no portfólio e apresentaria em uma entrevista técnica.
Visão Geral do Projeto
escola-api/
├── app/
│ ├── __init__.py
│ ├── main.py ← entrada da aplicação
│ ├── config.py ← configurações por ambiente
│ ├── database.py ← conexão SQLAlchemy
│ ├── seguranca.py ← hash de senha
│ ├── dependencies.py ← injeção de dependências
│ ├── models/
│ │ └── aluno.py ← Turma, Aluno, Disciplina e Nota
│ ├── schemas/
│ │ └── aluno.py ← modelos Pydantic
│ ├── repositories/
│ │ └── aluno_repo.py ← acesso ao banco
│ ├── services/
│ │ └── aluno_service.py ← lógica de negócio
│ └── routers/
│ ├── alunos.py
│ └── auth.py
├── tests/
│ ├── conftest.py
│ └── integration/
│ └── test_alunos.py
├── migrations/ ← Alembic
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── .env.example
Todas as pastas de app/ e tests/ levam um __init__.py vazio. Os pacotes do requirements.txt, conferidos em setembro de 2026: fastapi, uvicorn, sqlalchemy 2.1, psycopg[binary], pydantic-settings, email-validator, python-multipart (para o formulário de login), bcrypt, python-jose[cryptography] e alembic; para os testes, pytest e httpx.
Configuração e Banco de Dados
# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Configuracoes(BaseSettings):
# Aplicação
nome_app: str = "Escola API"
versao: str = "1.0.0"
debug: bool = False
ambiente: str = "producao"
# Banco de dados
database_url: str
database_pool_size: int = 5
database_max_overflow: int = 10
# Segurança
secret_key: str
algoritmo_jwt: str = "HS256"
expiracao_token_min: int = 30
# CORS
origens_permitidas: list[str] = ["http://localhost:3000"]
model_config = SettingsConfigDict(env_file=".env")
@lru_cache
def get_config() -> Configuracoes:
return Configuracoes()
# app/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
from typing import Generator
from app.config import get_config
config = get_config()
engine = create_engine(
config.database_url,
pool_size=config.database_pool_size,
max_overflow=config.database_max_overflow,
pool_pre_ping=True, # detecta conexões mortas
echo=config.debug
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
Um detalhe que mudou no SQLAlchemy 2.1: a URL postgresql://, sem driver explícito, passou a usar o psycopg 3, e não mais o psycopg2. Um projeto que instala psycopg2-binary, como era comum, quebra na primeira importação com ModuleNotFoundError: No module named 'psycopg'. Há duas saídas: instalar psycopg[binary], o caminho recomendado, ou declarar o driver antigo na URL, postgresql+psycopg2://. Escrever o driver na URL, qualquer que seja, evita depender do padrão da versão instalada.
Modelos ORM
# app/models/aluno.py
from sqlalchemy import (
Column, Integer, String, Float,
Boolean, DateTime, ForeignKey, Text
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Turma(Base):
__tablename__ = "turmas"
id = Column(Integer, primary_key=True, index=True)
nome = Column(String(50), nullable=False, unique=True)
turno = Column(String(20), nullable=False)
ano = Column(Integer, nullable=False)
ativa = Column(Boolean, default=True)
criado_em = Column(DateTime(timezone=True), server_default=func.now())
alunos = relationship("Aluno", back_populates="turma", lazy="dynamic")
def __repr__(self):
return f"<Turma {self.nome}>"
class Aluno(Base):
__tablename__ = "alunos"
id = Column(Integer, primary_key=True, index=True)
nome = Column(String(100), nullable=False)
email = Column(String(100), nullable=False, unique=True, index=True)
senha_hash = Column(String(255), nullable=False)
turma_id = Column(Integer, ForeignKey("turmas.id"), nullable=True)
ativo = Column(Boolean, default=True)
admin = Column(Boolean, default=False)
criado_em = Column(DateTime(timezone=True), server_default=func.now())
atualizado_em = Column(DateTime(timezone=True), onupdate=func.now())
turma = relationship("Turma", back_populates="alunos")
notas = relationship("Nota", back_populates="aluno", cascade="all, delete-orphan")
def __repr__(self):
return f"<Aluno {self.nome}>"
class Disciplina(Base):
__tablename__ = "disciplinas"
id = Column(Integer, primary_key=True)
nome = Column(String(80), nullable=False)
carga = Column(Integer, default=60) # horas
notas = relationship("Nota", back_populates="disciplina")
class Nota(Base):
__tablename__ = "notas"
id = Column(Integer, primary_key=True)
aluno_id = Column(Integer, ForeignKey("alunos.id", ondelete="CASCADE"))
disciplina_id = Column(Integer, ForeignKey("disciplinas.id", ondelete="CASCADE"))
valor = Column(Float, nullable=False)
periodo = Column(String(10), nullable=False) # ex: "2024-1"
criado_em = Column(DateTime(timezone=True), server_default=func.now())
aluno = relationship("Aluno", back_populates="notas")
disciplina = relationship("Disciplina", back_populates="notas")
Schemas Pydantic
# app/schemas/aluno.py
from pydantic import BaseModel, EmailStr, Field, field_validator
from typing import Optional
from datetime import datetime
class TurmaBase(BaseModel):
nome: str = Field(..., min_length=2, max_length=50)
turno: str = Field(..., pattern="^(manha|tarde|noite)$")
ano: int = Field(..., ge=2020, le=2030)
class TurmaCriar(TurmaBase):
pass
class TurmaResposta(TurmaBase):
id: int
ativa: bool
criado_em: datetime
model_config = {"from_attributes": True}
class AlunoBase(BaseModel):
nome: str = Field(..., min_length=2, max_length=100)
email: EmailStr
class AlunoCriar(AlunoBase):
senha: str = Field(..., min_length=8)
turma_id: Optional[int] = None
@field_validator("senha")
@classmethod
def validar_senha(cls, v: str) -> str:
import re
if not re.search(r"[A-Z]", v):
raise ValueError("Senha deve conter ao menos uma maiúscula.")
if not re.search(r"\d", v):
raise ValueError("Senha deve conter ao menos um número.")
# o bcrypt só aceita até 72 bytes; em UTF-8, "ç" ocupa 2
if len(v.encode()) > 72:
raise ValueError("Senha deve ter no máximo 72 bytes.")
return v
class AlunoAtualizar(BaseModel):
nome: Optional[str] = Field(None, min_length=2)
email: Optional[EmailStr] = None
turma_id: Optional[int] = None
class AlunoResposta(AlunoBase):
id: int
turma_id: Optional[int]
ativo: bool
admin: bool
criado_em: datetime
turma: Optional[TurmaResposta] = None
model_config = {"from_attributes": True}
class AlunoRespostaLista(BaseModel):
total: int
pagina: int
tamanho: int
alunos: list[AlunoResposta]
class NotaCriar(BaseModel):
disciplina_id: int
valor: float = Field(..., ge=0.0, le=10.0)
periodo: str = Field(..., pattern=r"^\d{4}-[12]$")
class NotaResposta(BaseModel):
id: int
disciplina_id: int
valor: float
periodo: str
model_config = {"from_attributes": True}
class TokenResposta(BaseModel):
access_token: str
token_type: str = "bearer"
expira_em: int
class LoginDados(BaseModel):
email: EmailStr
senha: str
Repositório
# app/repositories/aluno_repo.py
from sqlalchemy.orm import Session
from sqlalchemy import select, func, and_
from typing import Optional
from app.models.aluno import Aluno, Turma, Nota
class AlunoRepositorio:
def __init__(self, db: Session):
self.db = db
def criar(self, dados: dict) -> Aluno:
aluno = Aluno(**dados)
self.db.add(aluno)
self.db.commit()
self.db.refresh(aluno)
return aluno
def buscar_id(self, aluno_id: int) -> Optional[Aluno]:
return self.db.get(Aluno, aluno_id)
def buscar_email(self, email: str) -> Optional[Aluno]:
return self.db.scalar(
select(Aluno).where(Aluno.email == email)
)
def listar(
self,
pagina: int = 1,
tamanho: int = 10,
turma_id: Optional[int] = None,
apenas_ativos: bool = True,
busca: Optional[str] = None
) -> tuple[list[Aluno], int]:
stmt = select(Aluno)
filtros = []
if apenas_ativos:
filtros.append(Aluno.ativo == True)
if turma_id:
filtros.append(Aluno.turma_id == turma_id)
if busca:
filtros.append(Aluno.nome.ilike(f"%{busca}%"))
if filtros:
stmt = stmt.where(and_(*filtros))
total = self.db.scalar(
select(func.count()).select_from(stmt.subquery())
)
alunos = self.db.scalars(
stmt
.order_by(Aluno.nome)
.offset((pagina - 1) * tamanho)
.limit(tamanho)
).all()
return list(alunos), total or 0
def atualizar(self, aluno: Aluno, dados: dict) -> Aluno:
for campo, valor in dados.items():
setattr(aluno, campo, valor)
self.db.commit()
self.db.refresh(aluno)
return aluno
def deletar(self, aluno: Aluno) -> None:
aluno.ativo = False
self.db.commit()
def adicionar_nota(self, dados: dict) -> Nota:
nota = Nota(**dados)
self.db.add(nota)
self.db.commit()
self.db.refresh(nota)
return nota
def media_aluno(self, aluno_id: int, periodo: Optional[str] = None) -> float:
stmt = select(func.avg(Nota.valor)).where(Nota.aluno_id == aluno_id)
if periodo:
stmt = stmt.where(Nota.periodo == periodo)
return float(self.db.scalar(stmt) or 0.0)
Service
# app/seguranca.py
import bcrypt
def gerar_hash(senha: str) -> str:
return bcrypt.hashpw(senha.encode(), bcrypt.gensalt()).decode()
def verificar_senha(senha: str, senha_hash: str) -> bool:
return bcrypt.checkpw(senha.encode(), senha_hash.encode())
O hash de senha fica num módulo próprio, usado pelo service, pelo login e pelos testes. A primeira versão do projeto usava o CryptContext do passlib, e essa escolha hoje quebra. O passlib não tem versão nova desde 2020, e com o bcrypt 5.0, a versão atual, todo pwd_ctx.hash() falha — medido, até para "Senha123" — com ValueError: password cannot be longer than 72 bytes, levantado pela rotina que o passlib usa para testar o bcrypt na primeira chamada. Chamar o bcrypt diretamente resolve em duas funções. O limite de 72 bytes, porém, é real: o bcrypt 5 recusa senhas maiores, e 37 letras "ç" já dão 74 bytes. Por isso o schema valida o tamanho em bytes e devolve 422, em vez de deixar a API responder 500. Para projetos novos, a documentação do FastAPI passou a sugerir o pwdlib com Argon2.
# app/services/aluno_service.py
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.repositories.aluno_repo import AlunoRepositorio
from app.seguranca import gerar_hash
from app.schemas.aluno import AlunoCriar, AlunoAtualizar
from app.models.aluno import Aluno
class AlunoService:
def __init__(self, db: Session):
self.repo = AlunoRepositorio(db)
def cadastrar(self, dados: AlunoCriar) -> Aluno:
if self.repo.buscar_email(dados.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="E-mail já cadastrado."
)
aluno = self.repo.criar({
"nome": dados.nome,
"email": dados.email,
"senha_hash": gerar_hash(dados.senha),
"turma_id": dados.turma_id,
})
return aluno
def buscar_ou_404(self, aluno_id: int) -> Aluno:
aluno = self.repo.buscar_id(aluno_id)
if not aluno or not aluno.ativo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Aluno {aluno_id} não encontrado."
)
return aluno
def atualizar(self, aluno_id: int, dados: AlunoAtualizar) -> Aluno:
aluno = self.buscar_ou_404(aluno_id)
if dados.email and dados.email != aluno.email:
if self.repo.buscar_email(dados.email):
raise HTTPException(
status_code=409, detail="E-mail já em uso."
)
campos = dados.model_dump(exclude_unset=True)
return self.repo.atualizar(aluno, campos)
def deletar(self, aluno_id: int) -> None:
aluno = self.buscar_ou_404(aluno_id)
self.repo.deletar(aluno)
def lancar_nota(self, aluno_id: int, dados) -> None:
self.buscar_ou_404(aluno_id)
self.repo.adicionar_nota({
"aluno_id": aluno_id,
"disciplina_id": dados.disciplina_id,
"valor": dados.valor,
"periodo": dados.periodo,
})
Routers
# app/routers/alunos.py
from fastapi import APIRouter, Depends, status, Query
from sqlalchemy.orm import Session
from typing import Annotated, Optional
from app.database import get_db
from app.schemas.aluno import (
AlunoCriar, AlunoAtualizar, AlunoResposta,
AlunoRespostaLista, NotaCriar
)
from app.services.aluno_service import AlunoService
from app.models.aluno import Aluno
from app.dependencies import usuario_atual, usuario_admin
router = APIRouter(prefix="/alunos", tags=["Alunos"])
DbDep = Annotated[Session, Depends(get_db)]
AdminDep = Annotated[Aluno, Depends(usuario_admin)]
# As rotas são "def", não "async def": a Session do SQLAlchemy é síncrona,
# e o FastAPI roda rotas "def" num threadpool, sem travar o event loop
@router.get("/", response_model=AlunoRespostaLista)
def listar_alunos(
db: DbDep,
pagina: int = Query(1, ge=1),
tamanho: int = Query(10, ge=1, le=100),
turma_id: Optional[int] = Query(None),
busca: Optional[str] = Query(None, min_length=2)
):
service = AlunoService(db)
alunos, total = service.repo.listar(
pagina=pagina, tamanho=tamanho,
turma_id=turma_id, busca=busca
)
return AlunoRespostaLista(
total=total, pagina=pagina,
tamanho=tamanho, alunos=alunos
)
@router.post("/", response_model=AlunoResposta,
status_code=status.HTTP_201_CREATED)
def criar_aluno(dados: AlunoCriar, db: DbDep):
return AlunoService(db).cadastrar(dados)
@router.get("/{aluno_id}", response_model=AlunoResposta)
def buscar_aluno(aluno_id: int, db: DbDep):
return AlunoService(db).buscar_ou_404(aluno_id)
@router.patch("/{aluno_id}", response_model=AlunoResposta)
def atualizar_aluno(
aluno_id: int,
dados: AlunoAtualizar,
db: DbDep,
_: AdminDep
):
return AlunoService(db).atualizar(aluno_id, dados)
@router.delete("/{aluno_id}", status_code=status.HTTP_204_NO_CONTENT)
def deletar_aluno(aluno_id: int, db: DbDep, _: AdminDep):
AlunoService(db).deletar(aluno_id)
@router.post("/{aluno_id}/notas",
status_code=status.HTTP_201_CREATED)
def lancar_nota(aluno_id: int, dados: NotaCriar, db: DbDep, _: AdminDep):
AlunoService(db).lancar_nota(aluno_id, dados)
return {"mensagem": "Nota lançada com sucesso."}
@router.get("/{aluno_id}/media")
def media_aluno(
aluno_id: int,
db: DbDep,
periodo: Optional[str] = Query(None)
):
service = AlunoService(db)
service.buscar_ou_404(aluno_id)
media = service.repo.media_aluno(aluno_id, periodo)
return {
"aluno_id": aluno_id,
"media": round(media, 2),
"periodo": periodo or "geral",
"aprovado": media >= 6.0
}
A primeira versão declarava essas rotas com async def, e o custo disso não aparece num teste com uma requisição por vez. Dentro de uma função async, uma chamada síncrona ao banco segura o event loop inteiro: nenhuma outra requisição anda até ela terminar. Medido com o uvicorn e uma chamada bloqueante de 0,3 s, cinco requisições simultâneas levaram 1,52 s com async def, uma atrás da outra, e 0,31 s com def, porque o FastAPI executa rotas síncronas num pool de threads. A regra: async def só quando tudo o que a rota espera é assíncrono — um driver asyncpg, a AsyncSession do SQLAlchemy, o httpx.AsyncClient. Com a Session síncrona, def.
Dependências e Autenticação
# app/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from jose import JWTError, jwt
from typing import Annotated
from app.database import get_db
from app.models.aluno import Aluno
from app.config import get_config
config = get_config()
oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/login")
def usuario_atual(
token: Annotated[str, Depends(oauth2)],
db: Annotated[Session, Depends(get_db)]
) -> Aluno:
exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido ou expirado.",
headers={"WWW-Authenticate": "Bearer"}
)
try:
payload = jwt.decode(
token, config.secret_key,
algorithms=[config.algoritmo_jwt]
)
aluno_id = int(payload.get("sub", 0))
except (JWTError, ValueError):
raise exc
aluno = db.get(Aluno, aluno_id)
if not aluno or not aluno.ativo:
raise exc
return aluno
def usuario_admin(
aluno: Annotated[Aluno, Depends(usuario_atual)]
) -> Aluno:
if not aluno.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requer privilégios de administrador."
)
return aluno
Router de Autenticação
# app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from jose import jwt
from datetime import datetime, timedelta, timezone
from typing import Annotated
from app.database import get_db
from app.repositories.aluno_repo import AlunoRepositorio
from app.schemas.aluno import TokenResposta
from app.config import get_config
from app.seguranca import verificar_senha
router = APIRouter(prefix="/auth", tags=["Autenticação"])
config = get_config()
def criar_token(aluno_id: int, email: str, admin: bool) -> str:
exp = datetime.now(timezone.utc) + timedelta(minutes=config.expiracao_token_min)
return jwt.encode(
{"sub": str(aluno_id), "email": email, "admin": admin, "exp": exp},
config.secret_key, algorithm=config.algoritmo_jwt
)
@router.post("/login", response_model=TokenResposta)
def login(
form: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[Session, Depends(get_db)]
):
repo = AlunoRepositorio(db)
aluno = repo.buscar_email(form.username)
if not aluno or not verificar_senha(form.password, aluno.senha_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="E-mail ou senha incorretos.",
headers={"WWW-Authenticate": "Bearer"}
)
if not aluno.ativo:
raise HTTPException(status_code=400, detail="Conta desativada.")
return TokenResposta(
access_token=criar_token(aluno.id, aluno.email, aluno.admin),
expira_em=config.expiracao_token_min * 60
)
Main: Montando a Aplicação
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from app.config import get_config
from app.database import engine, Base
from app.routers import alunos, auth
import logging
logger = logging.getLogger("escola.main")
config = get_config()
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(f"Iniciando {config.nome_app} v{config.versao}")
Base.metadata.create_all(bind=engine) # cria tabelas em dev
yield
logger.info("Encerrando aplicação.")
def criar_app() -> FastAPI:
app = FastAPI(
title=config.nome_app,
version=config.versao,
lifespan=lifespan,
docs_url="/docs" if config.debug else None,
redoc_url="/redoc" if config.debug else None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=config.origens_permitidas,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.include_router(auth.router)
app.include_router(alunos.router)
@app.get("/health", tags=["Saúde"])
async def health():
return {"status": "healthy", "versao": config.versao}
return app
app = criar_app()
Docker e Docker Compose
# Dockerfile
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app/ ./app/
COPY migrations/ ./migrations/
COPY alembic.ini .
RUN adduser --disabled-password --gecos "" appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
# docker-compose.yml
services:
api:
build: .
container_name: escola-api
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql+psycopg://escola:${POSTGRES_PASSWORD}@db:5432/escola
- SECRET_KEY=${SECRET_KEY}
- DEBUG=false
- AMBIENTE=producao
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
container_name: escola-db
environment:
- POSTGRES_USER=escola
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} # do .env, fora do repositório
- POSTGRES_DB=escola
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U escola"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- api
volumes:
postgres_data:
Testes do Projeto
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import criar_app
from app.database import Base, get_db
from app.models.aluno import Aluno, Turma
from app.seguranca import gerar_hash
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine_test = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionTeste = sessionmaker(autocommit=False, autoflush=False, bind=engine_test)
@pytest.fixture(scope="session")
def app():
Base.metadata.create_all(bind=engine_test)
_app = criar_app()
_app.dependency_overrides[get_db] = lambda: SessionTeste()
yield _app
Base.metadata.drop_all(bind=engine_test)
@pytest.fixture
def client(app):
return TestClient(app)
@pytest.fixture
def db():
session = SessionTeste()
yield session
session.rollback()
session.close()
@pytest.fixture
def admin_token(client):
# Cria o admin uma vez só (o e-mail é único) e devolve um token
db = SessionTeste()
if db.query(Aluno).filter_by(email="admin@test.com").first() is None:
db.add(Aluno(
nome="Admin", email="admin@test.com",
senha_hash=gerar_hash("Senha123"),
admin=True
))
db.commit()
db.close()
resp = client.post("/auth/login", data={
"username": "admin@test.com",
"password": "Senha123"
})
return resp.json()["access_token"]
# tests/integration/test_alunos.py
class TestAPIAlunos:
def test_criar_aluno(self, client, admin_token):
resp = client.post("/alunos", json={
"nome": "Ana Silva",
"email": "ana@test.com",
"senha": "Senha123"
})
assert resp.status_code == 201
assert resp.json()["nome"] == "Ana Silva"
def test_criar_duplicado(self, client):
dados = {"nome": "Bruno", "email": "bruno@test.com", "senha": "Senha123"}
client.post("/alunos", json=dados)
resp = client.post("/alunos", json=dados)
assert resp.status_code == 409
def test_listar_paginado(self, client):
resp = client.get("/alunos?pagina=1&tamanho=5")
assert resp.status_code == 200
body = resp.json()
assert "total" in body
assert "alunos" in body
def test_buscar_nao_existe(self, client):
resp = client.get("/alunos/9999")
assert resp.status_code == 404
def test_deletar_requer_admin(self, client):
resp = client.delete("/alunos/1")
assert resp.status_code == 401
def test_deletar_como_admin(self, client, admin_token):
# Cria aluno
c = client.post("/alunos", json={
"nome": "Temp", "email": "temp@test.com", "senha": "Senha123"
})
aluno_id = c.json()["id"]
resp = client.delete(
f"/alunos/{aluno_id}",
headers={"Authorization": f"Bearer {admin_token}"}
)
assert resp.status_code == 204
A fixture admin_token tinha um defeito que só aparecia no segundo teste a usá-la: ela inseria o admin toda vez, e como o banco de teste dura a sessão inteira, a segunda inserção quebrava com IntegrityError: UNIQUE constraint failed: alunos.email. Agora ela só cria o admin se ele não existir. Com essa correção e as do resto do projeto, os seis testes passam, e a API foi exercitada também contra um PostgreSQL 18 de verdade: cadastro, senha longa recusada com 422, login, nota lançada pelo admin, média e exclusão barrada sem token.
O projeto junta, numa API só, o que a série construiu em partes. O FastAPI e o Pydantic de FastAPI: APIs modernas com tipagem e documentação automática recebem e validam as requisições; o SQLAlchemy grava no PostgreSQL por trás de um repositório; um service concentra as regras de negócio; o JWT e o bcrypt cuidam da autenticação; e o Docker Compose sobe a API, o banco e o proxy juntos. Os testes seguem o que se viu em Testes com pytest, e o pipeline de CI/CD com GitHub Actions é o passo natural seguinte.
Montar o projeto arquivo por arquivo e rodá-lo foi o que revelou o que a leitura não mostrava: um router importado que não existia, uma biblioteca de senhas que parou de funcionar com a versão atual do bcrypt, um driver de banco que mudou de padrão, rotas assíncronas travando o servidor e uma fixture que quebrava no segundo uso. Nenhum desses defeitos é de arquitetura; todos são de ambiente e de detalhe, e é por eles que um projeto de portfólio costuma falhar na frente de quem avalia. Fixar as versões no requirements.txt e rodar os testes a cada mudança de dependência é o que mantém o projeto de pé.
Fontes e leituras recomendadas
- FastAPI — documentação oficial — https://fastapi.tiangolo.com/
- SQLAlchemy 2.0 — https://docs.sqlalchemy.org/en/20/
- Alembic — migrações — https://alembic.sqlalchemy.org/
- pydantic-settings — https://docs.pydantic.dev/latest/concepts/pydantic_settings/
- Docker Compose — https://docs.docker.com/compose/
- PERCIVAL, Harry; GREGORY, Bob. Architecture Patterns with Python. O'Reilly Media, 2020. — Repository, Service Layer e Dependency Injection em Python.
Exercícios
Exercício 1
Um recrutador clona o projeto do seu portfólio, instala as dependências e tenta cadastrar um aluno com a senha "Senha123". A API responde 500, e o log mostra ValueError: password cannot be longer than 72 bytes, vindo do passlib. A senha tem 8 caracteres. Na sua máquina, instalada seis meses antes, tudo funcionava. O que mudou?
Ver resposta
✓ Resposta: A versão do bcrypt. O requirements.txt pedia passlib[bcrypt] sem fixar versões, e a instalação nova trouxe o bcrypt 5.0. O passlib 1.7.4, sem versão nova desde 2020, testa o backend do bcrypt na primeira chamada com uma senha longa, de propósito, para detectar um defeito antigo. O bcrypt 5 passou a recusar senhas acima de 72 bytes com ValueError, e esse teste interno quebra. Por isso qualquer hash falha, e a mensagem fala de um tamanho que a senha do usuário nem tem. Medido: passlib 1.7.4 com bcrypt 5.0.0, falha até para "Senha123". Há três lições. Dependência sem manutenção é risco, mesmo quando funciona hoje. O requirements.txt deveria fixar versões, ou o projeto deveria ter um lockfile, para que o recrutador instale exatamente o que você testou. E a correção é trocar a camada: chamar o bcrypt diretamente, como no app/seguranca.py, ou usar o pwdlib, que a documentação do FastAPI passou a recomendar.
Exercício 2
Com o bcrypt chamado diretamente, um usuário cadastra uma senha de 40 caracteres, quase todos letras acentuadas, com uma maiúscula e um número. A API responde 500. O schema exige no mínimo 8 caracteres, uma maiúscula e um número, e a senha cumpre tudo. Por que falhou, e como a API deveria responder?
Ver resposta
✓ Resposta: Porque o bcrypt conta bytes, não caracteres, e só aceita até 72. Em UTF-8, cada letra acentuada ocupa 2 bytes, então uma senha de 40 caracteres pode passar de 72 bytes. Medido: 37 letras "ç" dão 74 bytes, e o bcrypt.hashpw 5.0 levanta ValueError: password cannot be longer than 72 bytes. Sem validação, a exceção atravessa o service e vira um 500. Versões antigas do bcrypt truncavam em silêncio, o que era pior: duas senhas diferentes com os mesmos primeiros 72 bytes valiam como iguais. A correção é validar no schema, len(v.encode()) > 72, e devolver 422 com uma mensagem clara. Medido na API corrigida: 422. Alternativas são pré-processar a senha com SHA-256 antes do bcrypt, tomando cuidado com a codificação, ou usar Argon2, que não tem esse limite.
Exercício 3
Em produção, a API fica lenta nos horários de pico, mas o banco está ocioso e a CPU do servidor fica abaixo de 20%. As rotas usam a Session síncrona do SQLAlchemy e foram declaradas com async def. Explique a lentidão e o que mudar.
Ver resposta
✓ Resposta: Uma função async def roda direto no event loop, que é uma única thread. Quando ela faz uma chamada síncrona ao banco, o loop inteiro espera, e nenhuma outra requisição avança, nem as que não usam o banco. As requisições se enfileiram, a latência cresce, e nada parece sobrecarregado, porque o gargalo é a espera, não o processamento. Medido com uma chamada bloqueante de 0,3 s: cinco requisições simultâneas levaram 1,52 s com async def e 0,31 s com def. Com def, o FastAPI executa cada rota numa thread do pool, e as esperas se sobrepõem. A correção imediata é trocar para def, o que vale também para as dependências que tocam o banco, como usuario_atual. A alternativa é ir até o fim com o assíncrono: create_async_engine, AsyncSession e um driver assíncrono. O que não funciona é misturar os dois.
Exercício 4
O projeto sobe no Docker com DATABASE_URL=postgresql://escola:...@db:5432/escola e o requirements.txt com psycopg2-binary. Depois de atualizar as dependências, a API não inicia: ModuleNotFoundError: No module named 'psycopg'. O pacote psycopg2 está instalado. De onde vem o nome psycopg?
Ver resposta
✓ Resposta: Do padrão do SQLAlchemy. Uma URL postgresql:// sem driver explícito deixa a escolha para a biblioteca. Até a série 2.0, o padrão era o psycopg2; no SQLAlchemy 2.1, passou a ser o psycopg 3, cujo módulo se chama psycopg. Medido: com SQLAlchemy 2.1.1 e só o psycopg2 instalado, a criação do engine falha com exatamente essa mensagem. Há duas correções. A recomendada é instalar psycopg[binary], a versão mantida e com suporte a async. A outra é manter o psycopg2 e escrever o driver na URL, postgresql+psycopg2://. Em qualquer caso, vale declarar o driver na URL, como o compose corrigido faz com postgresql+psycopg://, porque assim uma mudança de padrão numa versão futura não afeta o projeto. É também mais um argumento para fixar versões: a atualização que trouxe o SQLAlchemy 2.1 não deveria ter entrado sem os testes rodarem.
Exercício 5
Na suíte original, test_criar_aluno passa, e test_deletar_como_admin, que roda depois, falha antes mesmo de começar, com IntegrityError: UNIQUE constraint failed: alunos.email dentro da fixture admin_token. Rodado sozinho, test_deletar_como_admin passa. Explique e corrija.
Ver resposta
✓ Resposta: A fixture admin_token tem escopo de função e roda a cada teste que a pede, mas o banco de teste tem escopo de sessão: as tabelas são criadas uma vez, e nada é apagado entre os testes. Na primeira vez, a fixture insere o admin; na segunda, tenta inserir de novo o mesmo admin@test.com, e a restrição de e-mail único barra. Rodado sozinho, o teste é o primeiro a usar a fixture e passa, o sinal clássico de estado vazando entre testes. A correção usada no artigo é idempotente: criar o admin só se ele não existir. Outras opções: dar à fixture escopo de sessão, para que o admin seja criado uma vez, ou isolar cada teste numa transação desfeita no fim, que é a solução mais robusta para suítes grandes, porque nenhum teste enxerga o que o anterior gravou. Com a correção, os seis testes do projeto passam.