Capítulo 33, Projetos
Blog API: posts, autorização, paginação e busca
O recurso principal, com as quatro operações, a regra "só o autor altera" e a listagem paginada com busca. É o capítulo que reúne os capítulos 17, 27 e 20.
As regras do recurso
| Operação | Quem pode | Detalhe |
|---|---|---|
| Listar e ler | Qualquer pessoa | Rotas públicas |
| Criar | Usuário logado | O autor é sempre o usuário do token, nunca um campo do corpo |
| Alterar e remover | Só o autor | 404 se não existe, 403 se existe e é de outro |
| Listar | Mais novos primeiro, páginas de até 50, busca e filtro por autor |
A segunda linha merece uma pausa. O corpo de criação não tem autor_id: se tivesse, qualquer usuário poderia criar um post em nome de outro. O autor vem do token. Esse princípio vale em geral: nunca aceite do cliente um dado que o servidor já sabe.
O código das rotas
Duas funções pequenas concentram as regras: buscar_ou_404 e exigir_autor. As rotas só as chamam, e a regra de permissão existe em um lugar só. Na listagem, a ordem é por id decrescente (os mais novos primeiro, e ordenar sempre é obrigatório para a paginação ser estável), a busca procura no título e no conteúdo com icontains(..., autoescape=True) (o % do usuário vira texto, como vimos no capítulo 27), e o total vem de uma contagem no banco:
from math import ceil
from typing import Annotated
from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.deps import Sessao, UsuarioAtual
from app.models import Post, Usuario
from app.schemas import Pagina, PostCriar, PostSaida
router = APIRouter(prefix="/posts", tags=["Posts"])
def buscar_ou_404(sessao: Session, post_id: int) -> Post:
post = sessao.get(Post, post_id)
if post is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Post não encontrado")
return post
def exigir_autor(post: Post, usuario: Usuario) -> None:
if post.autor_id != usuario.id:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Só o autor pode alterar este post")
@router.get("", response_model=Pagina[PostSaida])
def listar(
sessao: Sessao,
pagina: Annotated[int, Query(ge=1)] = 1,
limite: Annotated[int, Query(ge=1, le=50)] = 10,
busca: str | None = None,
autor_id: int | None = None,
) -> dict[str, object]:
consulta = select(Post).order_by(Post.id.desc())
if busca:
consulta = consulta.where(
or_(
Post.titulo.icontains(busca, autoescape=True),
Post.conteudo.icontains(busca, autoescape=True),
)
)
if autor_id is not None:
consulta = consulta.where(Post.autor_id == autor_id)
total = sessao.scalar(select(func.count()).select_from(consulta.subquery())) or 0
posts = sessao.scalars(consulta.offset((pagina - 1) * limite).limit(limite)).all()
return {
"pagina": pagina,
"limite": limite,
"total": total,
"paginas": ceil(total / limite),
"dados": posts,
}
@router.get("/{post_id}", response_model=PostSaida)
def obter(post_id: int, sessao: Sessao) -> Post:
return buscar_ou_404(sessao, post_id)
@router.post("", response_model=PostSaida, status_code=status.HTTP_201_CREATED)
def criar(dados: PostCriar, sessao: Sessao, usuario: UsuarioAtual) -> Post:
post = Post(titulo=dados.titulo, conteudo=dados.conteudo, autor_id=usuario.id)
sessao.add(post)
sessao.commit()
sessao.refresh(post)
return post
@router.put("/{post_id}", response_model=PostSaida)
def substituir(post_id: int, dados: PostCriar, sessao: Sessao, usuario: UsuarioAtual) -> Post:
post = buscar_ou_404(sessao, post_id)
exigir_autor(post, usuario)
post.titulo = dados.titulo
post.conteudo = dados.conteudo
sessao.commit()
sessao.refresh(post)
return post
@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
def remover(post_id: int, sessao: Sessao, usuario: UsuarioAtual) -> None:
post = buscar_ou_404(sessao, post_id)
exigir_autor(post, usuario)
sessao.delete(post)
sessao.commit()
O que vale a pena notar
403 ou 404? Se o post existe e é de outra pessoa, eu respondo 403 ("você não pode"). Em um sistema em que a existência do recurso também é segredo (um documento privado), o certo é responder 404 para os dois casos. Aqui os posts são públicos para leitura, então o 403 é honesto e mais útil para quem consome a API.
refresh depois do commit. O criado_em e o atualizado_em são preenchidos pelo banco, e o sessao.refresh(post) os traz de volta para a resposta (capítulo 16).
A contagem do total. O select(func.count()).select_from(consulta.subquery()) conta as linhas com os mesmos filtros da consulta, no banco. É uma consulta extra, mas é o que permite ao cliente mostrar "página 2 de 7".
Exercício 1
Posts do usuário logado
Acrescente a rota GET /usuarios/eu/posts, que liste só os posts de quem está logado. Dica: ela depende de UsuarioAtual, e os resultados saem da mesma consulta da listagem, filtrando por autor_id. Confira com o TestClient do projeto.