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.
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:
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":
@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
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()])
201 {'titulo': 'Estudar FastAPI', 'concluida': False, 'id': 1}
{'titulo': 'Escrever testes', 'concluida': True, 'id': 2}
['Estudar FastAPI', 'Escrever testes']
['Escrever testes']
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()))
{'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 example | Here | Why |
|---|---|---|
The client sends the id | The server generates | The 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 200 | 201 Created | The status indicates what happened |
DELETE returns a message | 204 No Content | There is no body to return |
A list traversed with for | Dictionary by id | The search is direct |
| A model for everything | Input, output, and partial | Each 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.