Skip to content

    Chapter 8, Basic

    Complete CRUD: a task list

    Creating, reading, updating, and removing is the skeleton of almost every API. Here I set up a complete in-memory CRUD, and I fix the points where the classic example gives wrong answers.

    The design

    A CRUD needs four routes over the same resource, and I organize them around three models: what the client sends to create (without id), what the API returns (with id), and what the client sends for a partial update (all optional fields). The id is generated by the server: letting the client choose their own id opens up space for collisions.

    basico/cap08_crud_tarefas.pylinhas 10 a 34
    from itertools import count
    
    from fastapi import FastAPI, HTTPException, status
    from fastapi.testclient import TestClient
    from pydantic import BaseModel, Field
    
    app = FastAPI()
    
    
    class TarefaEntrada(BaseModel):
        titulo: str = Field(min_length=1, max_length=100)
        concluida: bool = False
    
    
    class Tarefa(TarefaEntrada):
        id: int
    
    
    class TarefaParcial(BaseModel):
        titulo: str | None = Field(default=None, min_length=1, max_length=100)
        concluida: bool | None = None
    
    
    tarefas: dict[int, Tarefa] = {}
    proximo_id = count(1)
    

    A list would work, but a dictionary indexed by id finds a task directly, without going through everything. The real database (chapter 16) will take its place.

    Create and read

    The POST returns 201 (created), and the GET accepts an optional filter. The search for id is in a single function, which raises the 404 when not found:

    basico/cap08_crud_tarefas.pylinhas 39 a 63
    def buscar_ou_404(tarefa_id: int) -> Tarefa:
        tarefa = tarefas.get(tarefa_id)
        if tarefa is None:
            raise HTTPException(status.HTTP_404_NOT_FOUND, "Tarefa não encontrada")
        return tarefa
    
    
    @app.post("/tarefas", response_model=Tarefa, status_code=status.HTTP_201_CREATED)
    def criar(entrada: TarefaEntrada):
        tarefa = Tarefa(id=next(proximo_id), **entrada.model_dump())
        tarefas[tarefa.id] = tarefa
        return tarefa
    
    
    @app.get("/tarefas", response_model=list[Tarefa])
    def listar(concluida: bool | None = None):
        itens = list(tarefas.values())
        if concluida is not None:
            itens = [t for t in itens if t.concluida == concluida]
        return itens
    
    
    @app.get("/tarefas/{tarefa_id}", response_model=Tarefa)
    def obter(tarefa_id: int):
        return buscar_ou_404(tarefa_id)
    

    Update: PUT replaces, PATCH modifies

    The PUT replaces the entire resource: the client sends all fields. The PATCH only changes what was sent, and the model_dump(exclude_unset=True) from the previous chapter is what makes this possible, because it separates "the client sent false" from "the client sent nothing":

    basico/cap08_crud_tarefas.pylinhas 68 a 85
    @app.put("/tarefas/{tarefa_id}", response_model=Tarefa)
    def substituir(tarefa_id: int, entrada: TarefaEntrada):
        buscar_ou_404(tarefa_id)
        tarefas[tarefa_id] = Tarefa(id=tarefa_id, **entrada.model_dump())
        return tarefas[tarefa_id]
    
    
    @app.patch("/tarefas/{tarefa_id}", response_model=Tarefa)
    def alterar(tarefa_id: int, mudancas: TarefaParcial):
        atual = buscar_ou_404(tarefa_id)
        tarefas[tarefa_id] = atual.model_copy(update=mudancas.model_dump(exclude_unset=True))
        return tarefas[tarefa_id]
    
    
    @app.delete("/tarefas/{tarefa_id}", status_code=status.HTTP_204_NO_CONTENT)
    def remover(tarefa_id: int):
        buscar_ou_404(tarefa_id)
        del tarefas[tarefa_id]
    

    The DELETE returns 204 without body: succeeded, and there is nothing to show.

    The complete flow

    basico/cap08_crud_tarefas.pylinhas 90 a 97
    cliente = TestClient(app)
    
    a = cliente.post("/tarefas", json={"titulo": "Estudar FastAPI"})
    b = cliente.post("/tarefas", json={"titulo": "Escrever testes", "concluida": True})
    print(a.status_code, a.json())
    print(b.json())
    print([t["titulo"] for t in cliente.get("/tarefas").json()])
    print([t["titulo"] for t in cliente.get("/tarefas?concluida=true").json()])
    
    Output
    201 {'titulo': 'Estudar FastAPI', 'concluida': False, 'id': 1}
    {'titulo': 'Escrever testes', 'concluida': True, 'id': 2}
    ['Estudar FastAPI', 'Escrever testes']
    ['Escrever testes']
    
    basico/cap08_crud_tarefas.pylinhas 99 a 103
    print(cliente.put("/tarefas/1", json={"titulo": "Estudar FastAPI com calma"}).json())
    print(cliente.patch("/tarefas/1", json={"concluida": True}).json())
    print(cliente.delete("/tarefas/2").status_code)
    print(cliente.get("/tarefas/2").status_code, cliente.get("/tarefas/2").json())
    print(len(cliente.get("/tarefas").json()))
    
    Output
    {'titulo': 'Estudar FastAPI com calma', 'concluida': False, 'id': 1}
    {'titulo': 'Estudar FastAPI com calma', 'concluida': True, 'id': 1}
    204
    404 {'detail': 'Tarefa não encontrada'}
    1
    

    Notice in the PATCH: it only changed concluida, and the title that the PUT had just set remained intact.

    What I changed from the classic example

    Classic exampleHereWhy
    The client sends the idThe server generatesThe client can repeat an id existing
    return {"error": "não encontrado"}raise HTTPException(404)The first returns 200 with an error message, and the client does not realize it failed
    POST returns 200201 CreatedThe status indicates what happened
    DELETE returns a message204 No ContentThere is no body to return
    A list traversed with forDictionary by idThe search is direct
    A model for everythingInput, output, and partialEach has different fields

    Memory is not a database

    This data disappears when the server restarts, and each process has its copy: with two processes (--workers 2), a task created in one may not exist in the other. Use this only to learn. Chapter 16 replaces the dictionary with a real database, without changing the routes.

    Exercise 1

    Task statistics

    Add GET /estatisticas, which returns {"total": ..., "concluidas": ..., "pendentes": ...}. Test with new tasks, in a clean app.