Skip to content

    Chapter 33, Projects

    Blog API: posts, authorization, pagination, and search

    The main resource, with the four operations, the rule "only the author modifies" and the paginated listing with search. It is the chapter that brings together chapters 17, 27, and 20.

    The resource rules

    OperationWho canDetail
    List and readAnyonePublic routes
    CreateLogged-in userThe author is always the user of the token, never a field from the body
    Modify and removeOnly the author404 if it does not exist, 403 if it exists and belongs to another
    ListNewest first, pages of up to 50, search and filter by author

    The second line deserves a pause. The creation body does not have autor_id: if it had, any user could create a post on behalf of another. The author comes from the token. This principle generally applies: never accept from the client a data that the server already knows.

    The route code

    Two small functions concentrate the rules: buscar_ou_404 and exigir_autor. The routes only call them, and the permission rule exists in one place only. In the listing, the order is by id descending (newest first, and always sorting is mandatory for pagination to be stable), the search looks in the title and in the content with icontains(..., autoescape=True) (the % the user turns into text, as we saw in chapter 27), and the total comes from a count in the database:

    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()
    

    What is worth noting

    403 or 404? If the post exists and is from someone else, I respond 403 ("you cannot"). In a system where the existence of the resource is also a secret (a private document), the right thing is to respond 404 for both cases. Here the posts are public for reading, so the 403 is honest and more useful for those consuming the API.

    refresh after the commit. The criado_em and the atualizado_em are filled by the database, and the sessao.refresh(post) brings them back for the response (chapter 16).

    The total count. The select(func.count()).select_from(consulta.subquery()) counts the lines with the same filters of the query, in the database. It is an extra query, but it allows the client to show "page 2 of 7".

    Exercise 1

    Posts of the logged-in user

    Add the route GET /usuarios/eu/posts, which lists only the posts of the logged-in user. Tip: it depends on UsuarioAtual, and the results come from the same query of the listing, filtering by autor_id. Check with the TestClient of the project.