Pular para o conteúdo

    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çãoQuem podeDetalhe
    Listar e lerQualquer pessoaRotas públicas
    CriarUsuário logadoO autor é sempre o usuário do token, nunca um campo do corpo
    Alterar e removerSó o autor404 se não existe, 403 se existe e é de outro
    ListarMais 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:

    projeto_blog/blog_api/app/routers/posts.py
    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.