Skip to content

    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.

    VerbDoesSafeIdempotent
    GETReadsYesYes
    POSTCreates (or performs an action)NoNo
    PUTReplaces entirelyNoYes
    PATCHPartially altersNoDepends
    DELETERemovesNoYes

    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

    CodeWhen to use
    200Success with body
    201Resource created (return the resource)
    204Success without body (a DELETE, for example)
    400Malformed request
    401No credential, or invalid credential
    403Valid credential, but no permission
    404The resource does not exist
    409Conflict with the current state (canceling an already canceled order)
    422The format is correct, but the data does not pass validation
    429Request limit exceeded
    503Service 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:

    backend/cap53_rest_contratos.pylinhas 10 a 20
    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)
    
    Output
    [1, 2, 3] [3, 4, 5]
    

    The 3 appeared twice. With cursor:

    backend/cap53_rest_contratos.pylinhas 22 a 34
    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)
    
    Output
    [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:

    backend/cap53_rest_contratos.pylinhas 39 a 49
    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))
    
    Output
    {'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:

    backend/cap53_rest_contratos.pylinhas 54 a 67
    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"}))
    
    Output
    (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.