Pular para o conteúdo

    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étodoCaminhoAutenticaçãoEntradaSucessoErros possíveis
    POST/usuariosNãoJSON {nome, email, senha}201 usuário409 e-mail repetido, 422
    POST/auth/tokenNãoFormulário username (e-mail) e password200 token401
    GET/usuarios/euSim200 usuário401
    GET/postsNãoConsulta pagina, limite (até 50), busca, autor_id200 página de posts422
    GET/posts/{id}Não200 post404
    POST/postsSimJSON {titulo, conteudo}201 post401, 422
    PUT/posts/{id}Sim (só o autor)JSON {titulo, conteudo}200 post401, 403, 404, 422
    DELETE/posts/{id}Sim (só o autor)204 sem corpo401, 403, 404
    GET/saudeNão200 {"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:

    Estrutura do projeto
    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:

    Terminal
    # 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 apt no 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):

    sql
    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:

    Texto
    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

    Terminal
    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
    
    projeto_blog/blog_api/pyproject.toml
    [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:

    projeto_blog/blog_api/.env.example
    # 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):

    projeto_blog/blog_api/app/config.py
    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:

    projeto_blog/blog_api/app/database.py
    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)