Capítulo 31, Projetos
Blog API: visão geral e PostgreSQL
O projeto que junta tudo: uma API de blog com usuários, login, posts, autorização por autor, paginação, busca, PostgreSQL e testes. Neste capítulo eu defino o contrato, preparo o banco e monto a base da aplicação.
O que vamos construir
O curso original termina em uma API de blog com PostgreSQL, JWT e paginação. Eu mantenho esse roteiro, e corrijo o que o torna inseguro demais para ser chamado de projeto real: no original, a rota de login não pedia usuário nem senha (qualquer um recebia um token), não existia o conceito de usuário, e qualquer pessoa autenticada podia alterar o post de qualquer outra. Esta versão tem usuários com senha protegida, tokens que identificam quem está logado, e só o autor altera o próprio post.
O contrato da API, que é o que um frontend precisa saber:
| Método | Caminho | Autenticação | Entrada | Sucesso | Erros possíveis |
|---|---|---|---|---|---|
POST | /usuarios | Não | JSON {nome, email, senha} | 201 usuário | 409 e-mail repetido, 422 |
POST | /auth/token | Não | Formulário username (e-mail) e password | 200 token | 401 |
GET | /usuarios/eu | Sim | 200 usuário | 401 | |
GET | /posts | Não | Consulta pagina, limite (até 50), busca, autor_id | 200 página de posts | 422 |
GET | /posts/{id} | Não | 200 post | 404 | |
POST | /posts | Sim | JSON {titulo, conteudo} | 201 post | 401, 422 |
PUT | /posts/{id} | Sim (só o autor) | JSON {titulo, conteudo} | 200 post | 401, 403, 404, 422 |
DELETE | /posts/{id} | Sim (só o autor) | 204 sem corpo | 401, 403, 404 | |
GET | /saude | Não | 200 {"status": "ok"} |
A estrutura em camadas
Cada pasta e cada arquivo tem uma responsabilidade. Essa separação é o que permite testar cada peça e trocar uma sem quebrar as outras:
blog_api/
pyproject.toml
.env.example
app/
config.py # configurações vindas do ambiente
database.py # engine, sessão e a classe Base
models.py # tabelas (SQLAlchemy)
schemas.py # contratos de entrada e saída (Pydantic)
security.py # hash de senha e JWT
deps.py # dependências: sessão, configuração, usuário logado
routers/
auth.py # cadastro, login, dados do usuário
posts.py # CRUD de posts
main.py # monta a aplicação
tests/
conftest.py # fixtures: banco limpo por teste, usuários
test_auth.py
test_posts.py
Uma regra de dependência mantém isso saudável: as rotas (routers/) usam deps, models, schemas e security, e nenhuma dessas camadas importa as rotas. Os modelos não conhecem o Pydantic, e os esquemas não conhecem o banco.
O PostgreSQL
O curso usa o SQLite até aqui. Para um projeto real, o PostgreSQL é a escolha comum: suporta muitos acessos simultâneos, tem tipos e restrições mais ricos e é o que a maioria das plataformas oferece como banco gerenciado. Instalar:
# macOS (Homebrew)
brew install postgresql@16
brew services start postgresql@16
# Ubuntu ou Debian
sudo apt install postgresql
sudo service postgresql start
# Qualquer sistema com Docker
docker run --name pg-blog -e POSTGRES_PASSWORD=admin -p 5432:5432 -d postgres:16
O que eu testei
Eu executei este projeto no PostgreSQL 16.15 instalado pelo
aptno Ubuntu. Os comandos do Homebrew e do Docker acima seguem a documentação de cada ferramenta, mas eu não os rodei aqui.
Em vez de usar o usuário administrador postgres, eu crio um usuário e um banco só para esta aplicação, com o mínimo de poder. No psql (conecte com psql -U postgres):
CREATE ROLE blog LOGIN PASSWORD 'blog_senha';
CREATE DATABASE blog_db OWNER blog;
A aplicação encontra o banco por uma URL de conexão, que tem sempre a mesma anatomia:
postgresql+psycopg://blog:blog_senha@localhost:5432/blog_db
│ │ │ │ │ │ └─ nome do banco
│ │ │ │ │ └─ porta
│ │ │ │ └─ servidor
│ │ │ └─ senha
│ │ └─ usuário
│ └─ o driver Python (psycopg 3)
└─ o tipo de banco
O curso usa o psycopg2-binary. Eu uso o psycopg na versão 3, o driver atual, e por isso a URL começa com postgresql+psycopg://. A senha não vai no código: ela fica na variável de ambiente BLOG_BANCO_URL, no .env que não vai para o Git (capítulo 23).
O projeto e as dependências
uv init blog_api
cd blog_api
uv add "fastapi[standard]" sqlalchemy "psycopg[binary]" pydantic-settings pyjwt "pwdlib[argon2]"
uv add --dev pytest httpx2 mypy ruff
[project]
name = "blog-api"
version = "1.0.0"
description = "API de blog com FastAPI, PostgreSQL, JWT e testes"
requires-python = ">=3.10"
dependencies = [
"fastapi[standard]>=0.142",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.2",
"pydantic-settings>=2.4",
"pyjwt>=2.8",
"pwdlib[argon2]>=0.2",
]
[dependency-groups]
dev = ["pytest>=8", "httpx2", "mypy>=1.10", "ruff>=0.6"]
[tool.uv]
package = false
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.mypy]
python_version = "3.10"
strict = true
files = ["app"]
O arquivo .env.example é o modelo, que vai para o Git, com os nomes das variáveis. Quem clona o projeto o copia para .env e preenche os valores:
# Copie para .env e preencha. O arquivo .env NUNCA vai para o Git.
BLOG_CHAVE_SECRETA=gere-com-openssl-rand-hex-32
BLOG_BANCO_URL=postgresql+psycopg://blog:blog_senha@localhost:5432/blog_db
BLOG_EXPIRA_EM_MINUTOS=30
BLOG_ORIGENS_PERMITIDAS=["http://localhost:5173"]
Configuração e banco
As configurações usam o pydantic-settings do capítulo 23: tipadas, validadas na partida, e com a chave secreta protegida por SecretStr (e uma exigência de no mínimo 32 caracteres):
from functools import lru_cache
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Configuracoes(BaseSettings):
"""Tudo vem do ambiente (variáveis BLOG_*) ou do arquivo .env, e é validado na partida."""
model_config = SettingsConfigDict(env_file=".env", env_prefix="BLOG_", extra="ignore")
chave_secreta: SecretStr = Field(min_length=32)
banco_url: str = "sqlite:///./blog.db"
algoritmo: str = "HS256"
expira_em_minutos: int = Field(default=30, gt=0)
origens_permitidas: list[str] = Field(default_factory=list)
@lru_cache
def obter_configuracoes() -> Configuracoes:
# A chave obrigatória vem do ambiente, e o mypy não enxerga isso.
return Configuracoes() # type: ignore[call-arg]
O database.py cria o engine e a fábrica de sessões. A função criar_engine decide as opções pela URL: o SQLite em memória (que uso nos testes) precisa da conexão única que vimos no capítulo 16, e o PostgreSQL usa o pool padrão com pool_pre_ping, que testa a conexão antes de usá-la e se recupera de conexões derrubadas pelo servidor:
from typing import Any
from sqlalchemy import Engine, create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from sqlalchemy.pool import StaticPool
class Base(DeclarativeBase):
pass
def criar_engine(url: str) -> Engine:
"""SQLite em memória precisa de uma conexão única; PostgreSQL usa o pool padrão."""
if url.startswith("sqlite"):
opcoes: dict[str, Any] = {"connect_args": {"check_same_thread": False}}
if url in ("sqlite://", "sqlite:///:memory:"):
opcoes["poolclass"] = StaticPool
return create_engine(url, **opcoes)
return create_engine(url, pool_pre_ping=True)
def criar_fabrica(engine: Engine) -> "sessionmaker[Session]":
return sessionmaker(engine, expire_on_commit=False)