Pular para o conteúdo

    Capítulo 34, Projetos

    Blog API: juntando, testando e rodando

    A aplicação montada, a suíte de testes que roda no SQLite e no PostgreSQL de verdade, e a execução real contra o banco, com as respostas e o esquema que o PostgreSQL criou.

    A aplicação

    O criar_app é uma fábrica: recebe as configurações e devolve uma aplicação nova, com o seu próprio engine. Isso é o que permite ao teste criar uma aplicação com banco em memória, e à produção, outra apontada para o PostgreSQL. O lifespan cria as tabelas na partida e fecha as conexões no desligamento. O CORS só é ligado se houver origens configuradas, e com métodos e cabeçalhos listados (capítulo 22):

    projeto_blog/blog_api/app/main.py
    from collections.abc import AsyncIterator
    from contextlib import asynccontextmanager
    
    from fastapi import FastAPI
    from fastapi.middleware.cors import CORSMiddleware
    
    from app.config import Configuracoes, obter_configuracoes
    from app.database import Base, criar_engine, criar_fabrica
    from app.routers import auth, posts
    
    
    def criar_app(config: Configuracoes | None = None) -> FastAPI:
        config = config or obter_configuracoes()
        engine = criar_engine(config.banco_url)
    
        @asynccontextmanager
        async def lifespan(app: FastAPI) -> AsyncIterator[None]:
            # Para aprender e para os testes. Em produção, as tabelas vêm de migrações (Alembic).
            Base.metadata.create_all(engine)
            yield
            engine.dispose()
    
        app = FastAPI(title="Blog API", version="1.0.0", lifespan=lifespan)
        app.state.config = config
        app.state.engine = engine
        app.state.fabrica_sessao = criar_fabrica(engine)
    
        if config.origens_permitidas:
            app.add_middleware(
                CORSMiddleware,
                allow_origins=config.origens_permitidas,
                allow_credentials=True,
                allow_methods=["GET", "POST", "PUT", "DELETE"],
                allow_headers=["authorization", "content-type"],
            )
    
        app.include_router(auth.router)
        app.include_router(posts.router)
    
        @app.get("/saude", tags=["Infra"])
        def saude() -> dict[str, str]:
            return {"status": "ok"}
    
        return app
    
    
    app = criar_app()
    

    create_all e migrações

    O create_all cria as tabelas que não existem, e nada mais. Em produção, o esquema evolui por migrações (Alembic, capítulo 57 do curso de Python): uma coluna nova em uma tabela com dados reais não pode ser feita por create_all. Eu deixei no lifespan para o projeto rodar com um comando só, e o comentário no código registra que isso é para aprender e testar.

    Os testes

    O conftest.py entrega, a cada teste, uma aplicação com o banco limpo (e o apaga no fim, fechando também o pool de conexões: sem o engine.dispose(), cada teste deixaria uma conexão aberta no PostgreSQL), e funções para criar usuários já logados. A variável TEST_DATABASE_URL escolhe o banco: sem ela, SQLite em memória (rápido, sem instalar nada), e com ela, o PostgreSQL de verdade:

    projeto_blog/blog_api/tests/conftest.py
    import os
    
    # A chave precisa existir antes de importar a aplicação (que lê as configurações na partida).
    os.environ.setdefault("BLOG_CHAVE_SECRETA", "chave-de-teste-com-no-minimo-32-caracteres-0123")
    
    from collections.abc import Callable, Iterator  # noqa: E402
    
    import pytest  # noqa: E402
    from fastapi import FastAPI  # noqa: E402
    from fastapi.testclient import TestClient  # noqa: E402
    
    from app.config import Configuracoes  # noqa: E402
    from app.database import Base  # noqa: E402
    from app.main import criar_app  # noqa: E402
    
    # Por padrão, SQLite em memória. Para testar no PostgreSQL de verdade:
    #   TEST_DATABASE_URL=postgresql+psycopg://blog:blog_senha@localhost:5432/blog_teste uv run pytest
    URL_TESTE = os.environ.get("TEST_DATABASE_URL", "sqlite://")
    
    
    @pytest.fixture
    def app() -> Iterator[FastAPI]:
        aplicacao = criar_app(Configuracoes(banco_url=URL_TESTE, _env_file=None))
        yield aplicacao
        # Cada teste começa com o banco vazio: nenhum teste depende do que outro deixou.
        engine = aplicacao.state.engine
        Base.metadata.drop_all(engine)
        engine.dispose()  # fecha o pool: sem isso, cada teste deixaria uma conexão aberta
    
    
    @pytest.fixture
    def cliente(app: FastAPI) -> Iterator[TestClient]:
        with TestClient(app) as c:
            yield c
    
    
    Entrar = Callable[[str, str], dict[str, str]]
    
    
    @pytest.fixture
    def novo_usuario(cliente: TestClient) -> Entrar:
        """Cria um usuário, faz login e devolve o cabeçalho Authorization."""
    
        def criar(nome: str, email: str) -> dict[str, str]:
            senha = "senha-segura-123"
            cliente.post("/usuarios", json={"nome": nome, "email": email, "senha": senha})
            resposta = cliente.post("/auth/token", data={"username": email, "password": senha})
            return {"Authorization": f"Bearer {resposta.json()['access_token']}"}
    
        return criar
    
    
    @pytest.fixture
    def ana(novo_usuario: Entrar) -> dict[str, str]:
        return novo_usuario("Ana", "ana@exemplo.com")
    
    
    @pytest.fixture
    def bia(novo_usuario: Entrar) -> dict[str, str]:
        return novo_usuario("Bia", "bia@exemplo.com")
    

    Os testes de autenticação cobrem o que é mais perigoso errar: a resposta nunca traz a senha nem o hash, o e-mail repetido, as validações, a mesma resposta para e-mail inexistente e senha errada, e o token vencido:

    projeto_blog/blog_api/tests/test_auth.py
    import pytest
    from fastapi import FastAPI
    from fastapi.testclient import TestClient
    
    from app.security import criar_token
    
    CADASTRO = {"nome": "Ana", "email": "Ana@Exemplo.com", "senha": "senha-segura-123"}
    
    
    def test_cadastro_nao_devolve_a_senha_nem_o_hash(cliente: TestClient) -> None:
        resposta = cliente.post("/usuarios", json=CADASTRO)
        assert resposta.status_code == 201
        corpo = resposta.json()
        assert corpo["email"] == "ana@exemplo.com"
        assert set(corpo) == {"id", "email", "nome"}
    
    
    def test_email_repetido_devolve_409(cliente: TestClient) -> None:
        cliente.post("/usuarios", json=CADASTRO)
        repetido = cliente.post("/usuarios", json={**CADASTRO, "email": "ana@exemplo.com"})
        assert repetido.status_code == 409
    
    
    @pytest.mark.parametrize(
        "mudanca",
        [{"senha": "curta"}, {"email": "nao-e-email"}, {"nome": ""}, {"senha": "x" * 129}],
    )
    def test_cadastro_invalido_devolve_422(cliente: TestClient, mudanca: dict[str, str]) -> None:
        assert cliente.post("/usuarios", json={**CADASTRO, **mudanca}).status_code == 422
    
    
    def test_login_com_sucesso_e_acesso_aos_proprios_dados(cliente: TestClient) -> None:
        cliente.post("/usuarios", json=CADASTRO)
        login = cliente.post(
            "/auth/token", data={"username": "ana@exemplo.com", "password": "senha-segura-123"}
        )
        assert login.status_code == 200 and login.json()["token_type"] == "bearer"
        cabecalho = {"Authorization": f"Bearer {login.json()['access_token']}"}
        assert cliente.get("/usuarios/eu", headers=cabecalho).json()["email"] == "ana@exemplo.com"
    
    
    def test_senha_errada_e_email_inexistente_dao_a_mesma_resposta(cliente: TestClient) -> None:
        cliente.post("/usuarios", json=CADASTRO)
        errada = cliente.post("/auth/token", data={"username": "ana@exemplo.com", "password": "errada"})
        inexistente = cliente.post("/auth/token", data={"username": "zeca@x.com", "password": "errada"})
        assert errada.status_code == inexistente.status_code == 401
        assert errada.json() == inexistente.json()
    
    
    def test_sem_token_ou_com_token_ruim_devolve_401(cliente: TestClient) -> None:
        assert cliente.get("/usuarios/eu").status_code == 401
        ruim = cliente.get("/usuarios/eu", headers={"Authorization": "Bearer lixo"})
        assert ruim.status_code == 401
        assert ruim.headers["www-authenticate"] == "Bearer"
    
    
    def test_token_vencido_e_recusado(app: FastAPI, cliente: TestClient) -> None:
        cadastro = cliente.post("/usuarios", json=CADASTRO).json()
        vencido = criar_token(app.state.config, cadastro["id"], minutos=-1)
        resposta = cliente.get("/usuarios/eu", headers={"Authorization": f"Bearer {vencido}"})
        assert resposta.status_code == 401
    

    Os testes de posts cobrem as regras de negócio: exigir login, só o autor altera e remove (com a verificação de que o post não mudou depois do 403), a paginação inteira (ids sem repetição entre as páginas), a busca sem diferença de maiúsculas, o % tratado como texto e o filtro por autor:

    projeto_blog/blog_api/tests/test_posts.py
    from fastapi.testclient import TestClient
    
    NOVO = {"titulo": "Meu primeiro post", "conteudo": "Olá, mundo do FastAPI"}
    
    
    def criar_post(
        cliente: TestClient, cabecalho: dict[str, str], **mudancas: str
    ) -> dict[str, object]:
        resposta = cliente.post("/posts", json={**NOVO, **mudancas}, headers=cabecalho)
        assert resposta.status_code == 201, resposta.text
        corpo: dict[str, object] = resposta.json()
        return corpo
    
    
    def test_criar_exige_login(cliente: TestClient) -> None:
        assert cliente.post("/posts", json=NOVO).status_code == 401
    
    
    def test_criar_e_ler(cliente: TestClient, ana: dict[str, str]) -> None:
        criado = criar_post(cliente, ana)
        assert criado["titulo"] == NOVO["titulo"] and criado["atualizado_em"] is None
        lido = cliente.get(f"/posts/{criado['id']}")
        assert lido.status_code == 200 and lido.json()["conteudo"] == NOVO["conteudo"]
    
    
    def test_leitura_e_publica(cliente: TestClient, ana: dict[str, str]) -> None:
        criar_post(cliente, ana)
        assert cliente.get("/posts").status_code == 200
    
    
    def test_post_inexistente_devolve_404(cliente: TestClient) -> None:
        assert cliente.get("/posts/999").status_code == 404
    
    
    def test_validacao_do_corpo(cliente: TestClient, ana: dict[str, str]) -> None:
        assert cliente.post("/posts", json={"titulo": ""}, headers=ana).status_code == 422
        assert (
            cliente.post("/posts", json={"titulo": "x" * 201, "conteudo": "c"}, headers=ana).status_code
            == 422
        )
    
    
    def test_so_o_autor_altera_e_remove(
        cliente: TestClient, ana: dict[str, str], bia: dict[str, str]
    ) -> None:
        post_id = criar_post(cliente, ana)["id"]
        novo = {"titulo": "Título novo", "conteudo": "Conteúdo novo"}
    
        assert cliente.put(f"/posts/{post_id}", json=novo, headers=bia).status_code == 403
        assert cliente.delete(f"/posts/{post_id}", headers=bia).status_code == 403
        assert cliente.get(f"/posts/{post_id}").json()["titulo"] == NOVO["titulo"]
    
        alterado = cliente.put(f"/posts/{post_id}", json=novo, headers=ana)
        assert alterado.status_code == 200 and alterado.json()["titulo"] == "Título novo"
        assert alterado.json()["atualizado_em"] is not None
    
        assert cliente.delete(f"/posts/{post_id}", headers=ana).status_code == 204
        assert cliente.get(f"/posts/{post_id}").status_code == 404
        assert cliente.delete(f"/posts/{post_id}", headers=ana).status_code == 404
    
    
    def test_paginacao(cliente: TestClient, ana: dict[str, str]) -> None:
        for i in range(25):
            criar_post(cliente, ana, titulo=f"Post {i:02d}")
    
        primeira = cliente.get("/posts?limite=10").json()
        assert (primeira["total"], primeira["paginas"], len(primeira["dados"])) == (25, 3, 10)
        assert primeira["dados"][0]["titulo"] == "Post 24"  # os mais novos primeiro
    
        ultima = cliente.get("/posts?limite=10&pagina=3").json()
        assert len(ultima["dados"]) == 5
        assert cliente.get("/posts?pagina=99").json()["dados"] == []
    
        ids = [
            p["id"]
            for n in (1, 2, 3)
            for p in cliente.get(f"/posts?limite=10&pagina={n}").json()["dados"]
        ]
        assert len(ids) == len(set(ids)) == 25
        assert cliente.get("/posts?limite=51").status_code == 422
        assert cliente.get("/posts?pagina=0").status_code == 422
    
    
    def test_busca_no_titulo_e_no_conteudo(cliente: TestClient, ana: dict[str, str]) -> None:
        criar_post(cliente, ana, titulo="Introdução ao FastAPI", conteudo="rotas e modelos")
        criar_post(cliente, ana, titulo="Banco de dados", conteudo="usando SQLAlchemy")
        criar_post(cliente, ana, titulo="Outro assunto", conteudo="sobre fastapi também")
    
        por_titulo = cliente.get("/posts?busca=introdução").json()
        assert por_titulo["total"] == 1
        sem_caixa = cliente.get("/posts?busca=FASTAPI").json()
        assert sem_caixa["total"] == 2
        assert cliente.get("/posts?busca=nada-disso").json()["total"] == 0
    
    
    def test_busca_trata_o_percentual_como_texto(cliente: TestClient, ana: dict[str, str]) -> None:
        criar_post(cliente, ana, titulo="Promoção de 50% hoje")
        criar_post(cliente, ana, titulo="Sem símbolo")
        assert cliente.get("/posts?busca=%25").json()["total"] == 1
    
    
    def test_filtrar_por_autor(cliente: TestClient, ana: dict[str, str], bia: dict[str, str]) -> None:
        criar_post(cliente, ana)
        criar_post(cliente, bia)
        criar_post(cliente, bia)
        autor_bia = cliente.get("/usuarios/eu", headers=bia).json()["id"]
        assert cliente.get(f"/posts?autor_id={autor_bia}").json()["total"] == 2
    
    
    def test_resposta_nunca_expoe_dados_do_usuario(cliente: TestClient, ana: dict[str, str]) -> None:
        post = criar_post(cliente, ana)
        assert set(post) == {"id", "titulo", "conteudo", "autor_id", "criado_em", "atualizado_em"}
    
    
    def test_saude(cliente: TestClient) -> None:
        assert cliente.get("/saude").json() == {"status": "ok"}
    

    Rodar

    Terminal
    uv sync
    uv run pytest
    
    Saída
    ......................                                                   [100%]
    22 passed
    

    O mesmo conjunto, agora no PostgreSQL 16 de verdade, apontando para o banco de teste:

    Terminal
    TEST_DATABASE_URL="postgresql+psycopg://blog:blog_senha@localhost:5432/blog_teste" uv run pytest
    
    Saída
    ......................                                                   [100%]
    22 passed
    

    Os 22 testes passam nos dois bancos, sem alterar uma linha. Esse é o valor de testar contra o banco real: diferenças sutis (maiúsculas em buscas, restrições de unicidade, fusos horários) aparecem aqui, e não em produção. Para a qualidade do código:

    Terminal
    uv run mypy
    uv run ruff check .
    
    Saída
    Success: no issues found in 11 source files
    All checks passed!
    

    A API rodando de verdade

    Os testes usam um cliente em memória. Para ver o servidor real, aponte para o banco e rode:

    Terminal
    export BLOG_CHAVE_SECRETA="$(openssl rand -hex 32)"
    export BLOG_BANCO_URL="postgresql+psycopg://blog:blog_senha@localhost:5432/blog_db"
    uv run fastapi run app/main.py
    

    Eu subi o servidor assim, contra o PostgreSQL, e fiz um passeio pela API com um cliente HTTP. Estas são as respostas reais:

    Passeio pela API (servidor real, PostgreSQL 16)
    POST /usuarios               201 {"id": 1, "email": "ana@exemplo.com", "nome": "Ana"}
    POST /usuarios (repetido)    409 {"detail": "E-mail já cadastrado"}
    POST /auth/token             200 token_type=bearer partes_do_jwt=3
    POST /posts (sem token)      401 {"detail": "Not authenticated"}
    POST /posts                  201 {"id": 3, "titulo": "Post 3 sobre FastAPI", "autor_id": 1}
    GET /posts?limite=2          200 total=3 paginas=2 titulos=['Post 3 sobre FastAPI', 'Post 2 sobre FastAPI']
    GET /posts?busca=Post 2      200 total=1
    PUT /posts/1 (sem token)     401 {"detail": "Not authenticated"}
    DELETE /posts/3              204 ""
    GET /posts/3                 404 {"detail": "Post não encontrado"}
    

    A mensagem Not authenticated do 401 sem token vem do próprio FastAPI, em inglês. Para traduzi-la, você pode usar um tratador de HTTPException (capítulo 12) que mapeie a mensagem.

    O que o PostgreSQL criou

    Ao subir, o create_all criou as tabelas. Este é o esquema real da tabela posts, consultado com o psql (\d posts):

    psql: \d posts
                                            Table "public.posts"
        Column     |           Type           | Collation | Nullable |              Default
    ---------------+--------------------------+-----------+----------+-----------------------------------
     id            | integer                  |           | not null | nextval('posts_id_seq'::regclass)
     titulo        | character varying(200)   |           | not null |
     conteudo      | text                     |           | not null |
     autor_id      | integer                  |           | not null |
     criado_em     | timestamp with time zone |           | not null | now()
     atualizado_em | timestamp with time zone |           |          |
    Indexes:
        "posts_pkey" PRIMARY KEY, btree (id)
        "ix_posts_autor_id" btree (autor_id)
    Foreign-key constraints:
        "posts_autor_id_fkey" FOREIGN KEY (autor_id) REFERENCES usuarios(id) ON DELETE CASCADE
    

    Tudo o que escrevi nos modelos aparece aí: a chave primária, o índice em autor_id (a listagem filtra por ele), o now() como padrão do banco, e a chave estrangeira com ON DELETE CASCADE. E, na tabela usuarios, a coluna senha_hash guardou um hash que começa com $argon2id$: a senha de verdade não está em lugar nenhum.

    O que ainda falta para produção

    FaltaOnde está neste curso
    Migrações do esquemaAlembic (curso de Python, capítulo 57)
    Limitar tentativas de loginCapítulo 29 (o balde de fichas como dependência)
    Tokens de renovação e logout realCapítulo 20 (o aviso sobre JWT sem estado)
    Logs estruturados e métricasCurso de Python, capítulo 61 (Observabilidade)
    DeployCapítulo 30
    Cache na listagem públicaCapítulo 28

    Subir para o GitHub

    A última aula do curso original termina empurrando o código para um repositório. O fluxo é o de sempre (git init, git add, git commit, git push), com um cuidado: confira que o .gitignore contém .env, .venv e __pycache__ antes do primeiro commit. Um segredo que entrou no histórico do Git continua lá mesmo depois de apagado do arquivo, e a única solução segura é trocar o segredo.