Chapter 53, Backend
REST and API fundamentals
An API is a contract between those who offer it and those who consume it. When the contract is predictable, clients work without you needing to explain each route.
Code for this chapter: backend/cap53_rest_contratos.py
Resources, not actions
In REST you model things (resources) with plural URLs, and the HTTP verb tells what to do with them. POST /pedidos creates. GET /pedidos/7 reads. POST /criarPedido is the classic error: the verb is already in the method.
| Verb | Does | Safe | Idempotent |
|---|---|---|---|
GET | Reads | Yes | Yes |
POST | Creates (or performs an action) | No | No |
PUT | Replaces entirely | No | Yes |
PATCH | Partially alters | No | Depends |
DELETE | Removes | No | Yes |
Safe means it does not change state. Idempotent means that repeating the call gives the same result as calling it once. The POST is the only one that is not idempotent, and therefore it deserves the most attention.
Status codes that matter
| Code | When to use |
|---|---|
200 | Success with body |
201 | Resource created (return the resource) |
204 | Success without body (a DELETE, for example) |
400 | Malformed request |
401 | No credential, or invalid credential |
403 | Valid credential, but no permission |
404 | The resource does not exist |
409 | Conflict with the current state (canceling an already canceled order) |
422 | The format is correct, but the data does not pass validation |
429 | Request limit exceeded |
503 | Service unavailable |
Pagination: by offset or by cursor
Pagination by offset (?pagina=2) is the most intuitive and has a serious flaw: if someone creates an item while you are browsing, the items shift and you see a duplicate (or miss one). Pagination by cursor asks "what comes after the last one I saw?", and therefore it is stable:
itens = list(range(1, 11))
def por_deslocamento(itens, deslocamento, limite):
return itens[deslocamento : deslocamento + limite]
pagina_1 = por_deslocamento(itens, 0, 3)
itens.insert(0, 0) # alguém cria um item no começo enquanto você navega
pagina_2 = por_deslocamento(itens, 3, 3)
print(pagina_1, pagina_2)
[1, 2, 3] [3, 4, 5]
The 3 appeared twice. With cursor:
itens = list(range(1, 11))
def por_cursor(itens, depois_de, limite):
pagina = [i for i in itens if i > depois_de][:limite]
proximo = pagina[-1] if len(pagina) == limite else None
return pagina, proximo
pagina_1, cursor = por_cursor(itens, 0, 3)
itens.insert(0, 0)
pagina_2, cursor = por_cursor(itens, cursor, 3)
print(pagina_1, pagina_2)
[1, 2, 3] [4, 5, 6]
In a database, the cursor becomes WHERE id > :cursor ORDER BY id LIMIT :limite, which uses the primary key index and continues fast on page one million. The offset (OFFSET) needs to sweep and discard all previous rows.
Errors with a unique format
A client needs to handle errors programmatically. This requires that all API errors have the same format, and not free text that changes by route. The standard is the problem details (RFC 9457): an object with type, title, status, and detail, and the same response serves for people and for code:
def problema(status, tipo, titulo, detalhe, **extras):
return {
"tipo": f"https://exemplo.com/erros/{tipo}",
"titulo": titulo,
"status": status,
"detalhe": detalhe,
**extras,
}
print(problema(409, "pedido-ja-cancelado", "Conflito de estado", "O pedido 7 já está cancelado.", pedido_id=7))
{'tipo': 'https://exemplo.com/erros/pedido-ja-cancelado', 'titulo': 'Conflito de estado', 'status': 409, 'detalhe': 'O pedido 7 já está cancelado.', 'pedido_id': 7}
The Content-Type of this response is application/problem+json. The client decides by the type (stable), and the detalhe is for human reading only.
Idempotence in POST
The POST is not idempotent, but you can make it so. The client generates a key unique (Idempotency-Key) for each operation and resends it on retries. The server keeps the result from the first time and returns the same on subsequent requests. Thus, a network timeout does not generate two requests:
resultados = {}
def criar_pedido(chave, dados):
if chave in resultados:
return 200, resultados[chave]
pedido = {"id": len(resultados) + 1, **dados}
resultados[chave] = pedido
return 201, pedido
print(criar_pedido("k1", {"cliente": "Ana"}))
print(criar_pedido("k1", {"cliente": "Ana"}))
print(criar_pedido("k2", {"cliente": "Bia"}))
(201, {'id': 1, 'cliente': 'Ana'})
(200, {'id': 1, 'cliente': 'Ana'})
(201, {'id': 2, 'cliente': 'Bia'})
Notice the codes: 201 on creation and 200 when the key already existed. In production, the check "does this key already exist?" is not enough (two simultaneous requests pass through it together), and the guarantee comes from a restriction UNIQUE in the database. The complete API chapter shows this.
Versioning and evolving
A published API is hard to change because you do not control the clients. The rule is: adding fields and routes is compatible. Removing or renaming a field, changing a type, or tightening a validation breaks those who are already using it. For an incompatible change, publish a new version (/v2/pedidos) and keep the old one for an agreed period.
The contract as code
I write the contract first, in the form of schemas. FastAPI (next chapter) automatically generates the OpenAPI documentation from them, and the same definition is used to validate input, serialize output, and document. Handwritten documentation ages, and the one generated from code does not.
Exercise 1
Cursor pagination
Write paginar(itens, cursor, limite) that returns {"itens": [...], "proximo_cursor": ...}. Fetch one more item than the limit to know if there is a next page, and use None when there isn't.