La web de la lliçó anterior és perfecta per a en Lluís i el seu navegador, però la Júlia vol una app mòbil, i una app mòbil no vol taules HTML: vol dades crues que pugui pintar a la seva manera. Aquesta és la feina d'una API REST: les mateixes rutes i verbs HTTP de sempre, però amb JSON al cos — el format que domines des de M6. En aquesta lliçó construiràs l'API completa de Papyrus: llistar el catàleg, consultar un llibre (i traduir LlibreNoTrobatError a un 404 net, tancant el cercle que vam obrir a M7), donar d'alta llibres amb validació seriosa, actualitzar estoc i esborrar. La provaràs des del terminal amb curl i des de pytest amb el test client, i en sortiràs amb el contracte de l'API documentat com ho faria un professional.

Contingut

  1. Què és REST: recursos, verbs, sense estat
  2. Disseny de l'API de Papyrus
  3. GET /api/llibres: la llista (amb jsonify i asdict)
  4. GET /api/llibres/<titol>: el detall i el 404 amb errorhandler
  5. POST /api/llibres: crear amb validació (201 o 400)
  6. PUT i DELETE: actualitzar estoc i esborrar
  7. Provar amb curl
  8. Provar amb el test client de pytest
  9. Món real: versionat /api/v1 i paginació
  10. El contracte de l'API, documentat

Què és REST: recursos, verbs, sense estat

REST (Representational State Transfer) no és una tecnologia sinó un estil: una manera de dissenyar APIs perquè qualsevol desenvolupador les entengui sense llegir el manual. Els seus principis pràctics:

Principi Què significa A Papyrus
Tot és un recurs amb URL pròpia Les URLs anomenen coses (substantius), no accions /api/llibres, /api/llibres/Hamlet
Els verbs HTTP són les accions GET llegeix, POST crea, PUT actualitza, DELETE esborra — mai /api/esborrarLlibre DELETE /api/llibres/Hamlet
Sense estat (stateless) Cada petició és autosuficient; el servidor no recorda l'anterior Dos GET seguits no depenen entre si
Representacions El recurs viatja en un format acordat, gairebé sempre JSON {"titol": "Hamlet", ...}
Codis d'estat amb significat La resposta s'autodescriu: 200, 201, 400, 404... La taula final d'aquesta lliçó

L'absència d'estat és la que més estranya al principi i la més valuosa: com que el servidor no guarda memòria de converses, qualsevol servidor idèntic pot atendre la petició següent — així s'escala a milions d'usuaris. El preu: tot el que la petició necessiti ha de viatjar dins d'ella.

Disseny de l'API de Papyrus

Abans de codificar, es dissenya. El recurs és el llibre; la col·lecció, els llibres. D'aquí surt tot el mapa:

flowchart LR
    A["/api/llibres<br/>(col·lecció)"] -->|GET| B["Llistar catàleg → 200"]
    A -->|POST| C["Crear llibre → 201 / 400"]
    D["/api/llibres/&lt;titol&gt;<br/>(recurs)"] -->|GET| E["Detall → 200 / 404"]
    D -->|PUT| F["Actualitzar estoc → 200 / 400 / 404"]
    D -->|DELETE| G["Esborrar → 204 / 404"]

Muntem l'API al mateix app.py de 10-02 (la web HTML i l'API conviuen sense problema), amb el prefix /api per separar-les.

GET /api/llibres: la llista (amb jsonify i asdict)

from dataclasses import asdict
from flask import Flask, jsonify, request
from papyrus.magatzem import carregar_cataleg, obtenir_llibre, desar_cataleg
from papyrus.models import Llibre
from papyrus.errors import LlibreNoTrobatError

app = Flask(__name__)
cataleg = carregar_cataleg("dades/cataleg.json")

@app.route("/api/llibres")
def api_llistar():
    return jsonify([asdict(llibre) for llibre in cataleg.values()])

Aquí se solden tres mòduls del curs en una línia:

  • asdict(llibre) (M5) converteix cada dataclass Llibre en un diccionari: {"titol": "L'Odissea", "preu": 12.5, "estoc": 4}.
  • La llista per comprensió (M4) ho fa per a tot el catàleg.
  • jsonify(...) (el json.dumps de M6 amb esteroides) serialitza a JSON i a més munta la resposta HTTP completa: capçalera Content-Type: application/json i codi 200.

La resposta que rebrà l'app de la Júlia:

[
  {"titol": "L'Odissea", "preu": 12.5, "estoc": 4},
  {"titol": "Hamlet", "preu": 9.95, "estoc": 6},
  {"titol": "El Quixot", "preu": 15.9, "estoc": 8},
  {"titol": "Faust", "preu": 21.0, "estoc": 10}
]

GET /api/llibres/<titol>: el detall i el 404 amb errorhandler

Per al detall fem servir obtenir_llibre() — la variant de M7 que llança LlibreNoTrobatError en lloc de retornar None. I qui captura aquesta excepció? Podríem posar un try/except a cada vista... o dir-ho a Flask una sola vegada:

@app.errorhandler(LlibreNoTrobatError)
def llibre_no_trobat(error):
    return jsonify({"error": str(error)}), 404

@app.route("/api/llibres/<titol>")
def api_detall(titol):
    llibre = obtenir_llibre(cataleg, titol)   # pot llancar LlibreNoTrobatError
    return jsonify(asdict(llibre))

Aquest és el moment en què M7 i 10-01 es donen la mà, i mereix que l'assaboreixis:

  • @app.errorhandler(LlibreNoTrobatError) — un altre decorador de registre (08-02, ja en van tres) — declara: "quan qualsevol vista deixi escapar aquesta excepció, respon així".
  • La vista queda neta: demana el llibre i el retorna. El cas d'error ni hi apareix: l'excepció puja (M7: les excepcions viatgen cap amunt fins que algú les captura) i el gestor la tradueix a 404 amb un JSON explicatiu.
  • La teva jerarquia d'errors.py cobra un valor nou: com que totes hereten d'ErrorPapyrus, podries registrar un gestor genèric per a la família sencera. Disseny de M7, dividends a M10.

Resposta per a GET /api/llibres/Rayuela:

{"error": "'Rayuela' no es al cataleg"}

amb codi 404 Not Found. L'app de la Júlia mira el codi, veu 404, i mostra "llibre no disponible" — sense endevinar res.

POST /api/llibres: crear amb validació (201 o 400)

Crear exigeix rebre dades. El client les envia com a JSON al cos i Flask les lliura amb request.get_json():

@app.route("/api/llibres", methods=["POST"])
def api_crear():
    dades = request.get_json(silent=True)
    if dades is None:
        return jsonify({"error": "S'esperava un cos JSON"}), 400
    try:
        llibre = Llibre(titol=str(dades["titol"]),
                        preu=float(dades["preu"]),
                        estoc=int(dades.get("estoc", 0)))
    except (KeyError, TypeError, ValueError) as exc:
        return jsonify({"error": f"Dades invalides: {exc}"}), 400
    if llibre.preu <= 0:
        return jsonify({"error": "El preu ha de ser positiu"}), 400
    if llibre.titol in cataleg:
        return jsonify({"error": f"'{llibre.titol}' ja existeix"}), 400
    cataleg[llibre.titol] = llibre
    desar_cataleg(cataleg, "dades/cataleg.json")
    return jsonify(asdict(llibre)), 201

Desgranem les decisions:

  • methods=["POST"]: per defecte una ruta només accepta GET; aquí declarem el verb. La mateixa URL /api/llibres té dues vistes — GET llista, POST crea — i això és REST pur.
  • request.get_json(silent=True) retorna el dict (M4) del cos, o None si el cos no és JSON vàlid — que convertim en un 400 educat en lloc d'un 500 vergonyant.
  • La dataclass valida la forma: construir Llibre(...) amb conversions explícites (float, int) fa saltar ValueError si arriba "preu": "gratis", i KeyError si falta titol. Capturem la terna i responem 400 amb el motiu — un error útil estalvia hores a qui fa servir la teva API (la lliçó dels missatges d'error de M7, ara de cara al públic).
  • Validacions de negoci (preu positiu, duplicats) → també 400.
  • Èxit → 201 Created (no 200: creat és informació) i el llibre acabat de crear com a cos, perquè el client confirmi com ha quedat. desar_cataleg (M6) persisteix el canvi a dades/cataleg.json.

PUT i DELETE: actualitzar estoc i esborrar

Amb els patrons ja vistos, les dues operacions restants surten soles:

@app.route("/api/llibres/<titol>", methods=["PUT"])
def api_actualitzar(titol):
    llibre = obtenir_llibre(cataleg, titol)         # 404 automatic si no existeix
    dades = request.get_json(silent=True) or {}
    if "estoc" not in dades:
        return jsonify({"error": "Falta el camp 'estoc'"}), 400
    try:
        nou_estoc = int(dades["estoc"])
    except (TypeError, ValueError):
        return jsonify({"error": "'estoc' ha de ser un enter"}), 400
    if nou_estoc < 0:
        return jsonify({"error": "L'estoc no pot ser negatiu"}), 400
    llibre.estoc = nou_estoc
    desar_cataleg(cataleg, "dades/cataleg.json")
    return jsonify(asdict(llibre))                  # 200 amb l'estat final

@app.route("/api/llibres/<titol>", methods=["DELETE"])
def api_esborrar(titol):
    obtenir_llibre(cataleg, titol)                  # valida existencia → 404
    del cataleg[titol]
    desar_cataleg(cataleg, "dades/cataleg.json")
    return "", 204                                  # sense contingut: res a dir

Fixa't en el rendiment que dona l'errorhandler: totes dues vistes comencen amb obtenir_llibre i no gestionen el cas "no existeix" — l'excepció de M7 i el gestor se n'ocupen. El 204 No Content del DELETE és el codi canònic per a "fet, i no hi ha cos a retornar".

Provar amb curl

curl és el client HTTP de terminal universal: amb ell toques la teva API sense escriure ni una línia de frontend. Amb el servidor corrent (flask --app app run --debug):

# Llistar el cataleg
curl http://127.0.0.1:5000/api/llibres

# Detall (URL amb apostrof: entre cometes dobles i codificat com a %27)
curl "http://127.0.0.1:5000/api/llibres/L%27Odissea"

# Un 404 amb missatge util
curl -i http://127.0.0.1:5000/api/llibres/Rayuela

# Crear un llibre (POST amb cos JSON)
curl -X POST http://127.0.0.1:5000/api/llibres \
     -H "Content-Type: application/json" \
     -d '{"titol": "Faust II", "preu": 18.50, "estoc": 3}'

# Actualitzar estoc (PUT)
curl -X PUT http://127.0.0.1:5000/api/llibres/Hamlet \
     -H "Content-Type: application/json" \
     -d '{"estoc": 9}'

# Esborrar (DELETE)
curl -X DELETE "http://127.0.0.1:5000/api/llibres/Faust%20II"

Xuleta: -X fixa el verb, -H afegeix una capçalera (declarar Content-Type: application/json és obligatori perquè get_json() funcioni sense ensurts), -d és el cos, -i mostra també les capçaleres i el codi d'estat de la resposta. Executa els sis i observa els codis: 200, 200, 404, 201, 200, 204 — la teva API diu la veritat.

Provar amb el test client de pytest

I com tot a Papyrus des de M9: si no té test, no existeix. Flask inclou un test client que simula peticions sense aixecar servidor, i encaixa directe a la teva suite:

# tests/test_api.py
import pytest
from app import app

@pytest.fixture
def client():
    app.config["TESTING"] = True
    return app.test_client()

def test_llistar_retorna_el_cataleg(client):
    resposta = client.get("/api/llibres")
    assert resposta.status_code == 200
    titols = [llibre["titol"] for llibre in resposta.get_json()]
    assert "L'Odissea" in titols

def test_llibre_inexistent_dona_404_amb_missatge(client):
    resposta = client.get("/api/llibres/Rayuela")
    assert resposta.status_code == 404
    assert "Rayuela" in resposta.get_json()["error"]

Les peces són les de M9 — fixture, asserts, pytest en verd — aplicades a HTTP: client.get(...) fa la petició en memòria i resposta.get_json() desfà el JSON. No hi aprofundim més (M9 ja et va donar l'ofici), però que quedi la llavor: els contractes de la teva API — codis inclosos — es protegeixen amb regressions igual que els preus de soci.

Món real: versionat /api/v1 i paginació

Dues pràctiques que veuràs a tota API professional i que aquí només deixem anomenades, amb honestedat:

  • Versionat: les APIs públiques prefixen la versió — /api/v1/llibres — per poder llançar una /api/v2 incompatible sense trencar les apps que ja fan servir la v1. Amb quatre llibres i un client (la Júlia) no ens cal; amb clients de debò, és el primer que es decideix.
  • Paginació: GET /api/llibres retorna el catàleg sencer. Amb 4 llibres, perfecte; amb 40.000, seria un regal enverinat. Les APIs reals retornen pàgines (/api/llibres?pagina=2&per_pagina=50) i metadades de navegació. Recorda-ho el dia que la teva col·lecció creixi.

El contracte de l'API, documentat

Tota API mereix el seu contracte per escrit. Aquest és el de Papyrus v1 — la taula que passaries a qui programi l'app de la Júlia:

Ruta Verb Cos d'entrada Èxit Errors
/api/llibres GET 200 + llista de llibres
/api/llibres POST {"titol", "preu", "estoc"?} 201 + llibre creat 400 dades invàlides o duplicat
/api/llibres/<titol> GET 200 + llibre 404 no existeix
/api/llibres/<titol> PUT {"estoc": enter ≥ 0} 200 + llibre actualitzat 400 estoc invàlid, 404 no existeix
/api/llibres/<titol> DELETE 204 sense cos 404 no existeix

Tots els errors comparteixen format: {"error": "missatge llegible"}. Aquesta consistència també és part del contracte.

Errors Comuns i Consells

  • Oblidar Content-Type: application/json al client: request.get_json() retorna None i el teu codi esclata més endavant. Amb silent=True + comprovació de None respons un 400 clar en lloc d'un 500 misteriós.
  • 405 Method Not Allowed: has fet POST a una ruta que només declara GET (o a l'inrevés). Revisa methods=[...] — és l'oblit més freqüent de la lliçó.
  • Retornar 200 als errors: el pecat capital de 10-01, ara amb conseqüències reals — els clients automatitzats miren el codi abans que el cos. Codi i cos han d'explicar la mateixa història.
  • Apòstrofs i espais a les URLs de curl: L'Odissea ha de viatjar com L%27Odissea (i Faust II com Faust%20II), amb la URL entre cometes dobles; si no, el shell interpreta l'apòstrof o l'espai pel seu compte i curl rep una altra cosa.
  • Validar poc el POST: "ja validarà el client" és la trampa de 10-01 — qualsevol amb curl esquiva el teu formulari. El backend valida sempre: tipus, rangs, duplicats, i respon 400 amb el motiu.
  • Capturar Exception a l'errorhandler: registra gestors per a excepcions concretes (LlibreNoTrobatError). Un gestor d'Exception que respon 404 a tot amaga bugs autèntics que haurien de ser 500 i sortir a papyrus.log.

Exercicis

Exercici 1: endpoint de venda

Afegeix POST /api/llibres/<titol>/venda que rebi {"unitats": n, "soci": bool} i cridi el vendre() de M7 (retorna la quantia i descompta estoc). Respon 200 amb {"titol", "unitats", "quantia"}. EstocInsuficientError s'ha de convertir en 409 Conflict (el codi per a "la petició és vàlida però xoca amb l'estat actual") mitjançant un errorhandler nou — sense try/except a la vista.

Exercici 2: contracte sota test

Escriu dos tests amb el test client: (a) POST /api/llibres sense el camp preu respon 400 i el missatge esmenta el problema; (b) després d'un PUT que fixa l'estoc de Hamlet a 9, un GET de detall retorna estoc == 9.

Exercici 3: filtre per disponibilitat

Amplia GET /api/llibres amb el paràmetre opcional ?disponibles=si que filtri amb hi_ha_estoc() (M5). Sense el paràmetre, comportament actual intacte — que un canvi no trenqui el contracte existent és mitja professió.

Solucions

Exercici 1:

from papyrus.errors import EstocInsuficientError
from papyrus.magatzem import vendre

@app.errorhandler(EstocInsuficientError)
def sense_estoc(error):
    return jsonify({"error": str(error)}), 409

@app.route("/api/llibres/<titol>/venda", methods=["POST"])
def api_vendre(titol):
    dades = request.get_json(silent=True) or {}
    unitats = int(dades.get("unitats", 1))
    quantia = vendre(cataleg, titol, unitats)   # 404 o 409 automatics
    desar_cataleg(cataleg, "dades/cataleg.json")
    return jsonify({"titol": titol, "unitats": unitats,
                    "quantia": round(quantia, 2)})

La vista no té ni un try: LlibreNoTrobatError → 404 i EstocInsuficientError → 409 els resolen els gestors. Les teves excepcions de M7 s'han convertit en el sistema d'errors de l'API.

Exercici 2:

def test_post_sense_preu_dona_400(client):
    resposta = client.post("/api/llibres", json={"titol": "Iliada"})
    assert resposta.status_code == 400
    assert "preu" in resposta.get_json()["error"]

def test_put_actualitza_estoc(client):
    client.put("/api/llibres/Hamlet", json={"estoc": 9})
    resposta = client.get("/api/llibres/Hamlet")
    assert resposta.get_json()["estoc"] == 9

L'argument json= del test client serialitza el cos i posa el Content-Type per tu. (Purista de M9: aquests tests muten el catàleg compartit; una fixture que el restauri amb tmp_path seria el refinament següent.)

Exercici 3:

@app.route("/api/llibres")
def api_llistar():
    llibres = cataleg.values()
    if request.args.get("disponibles") == "si":
        llibres = [llibre for llibre in llibres if llibre.hi_ha_estoc()]
    return jsonify([asdict(llibre) for llibre in llibres])

Paràmetre absent → la branca nova ni s'executa: el contracte de la taula final continua sent cert paraula per paraula.

Conclusió

Papyrus ja parla els dos idiomes de la web: HTML per a persones (10-02) i JSON per a programes. L'API cobreix el cicle complet del recurs llibre — llistar, detallar, crear, actualitzar, esborrar — amb els verbs i codis correctes, i el millor és quant de curs vell sosté el que és nou: asdict() de M5 alimenta jsonify, el JSON de M6 és el cos de cada resposta, i LlibreNoTrobatError de M7 es tradueix en 404 amb un errorhandler d'una sola peça — el decorador de 08-02 treballant una altra vegada. Has provat el contracte amb curl des de fora i amb el test client de pytest des de dins, i has deixat documentada la taula que qualsevol client necessita. Flask t'ha ensenyat les peces una a una: ruta, vista, plantilla, JSON, error. La pregunta natural és què passa quan el projecte creix i vols que algú et doni fetes les peces repetitives — persistència real, panell d'administració, formularis amb validació automàtica, usuaris. Aquesta filosofia oposada i complementària té nom: Django, el framework amb les bateries incloses. A la propera lliçó arrenquem un projecte Django des de zero i veuràs la teva dataclass Llibre renéixer com a model amb persistència regalada.

Curs de Programació en Python

Mòdul 1: Introducció a Python

Mòdul 2: Estructures de Control

Mòdul 3: Funcions i Mòduls

Mòdul 4: Estructures de Dades

Mòdul 5: Programació Orientada a Objectes

Mòdul 6: Gestió de Fitxers

Mòdul 7: Gestió d'Errors i Excepcions

Mòdul 8: Temes Avançats

Mòdul 9: Proves i Depuració

Mòdul 10: Desenvolupament Web amb Python

Mòdul 11: Ciència de Dades amb Python

Mòdul 12: Projecte Final

© Copyright 2026. Tots els drets reservats