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
- Què és REST: recursos, verbs, sense estat
- Disseny de l'API de Papyrus
GET /api/llibres: la llista (ambjsonifyiasdict)GET /api/llibres/<titol>: el detall i el 404 amberrorhandlerPOST /api/llibres: crear amb validació (201 o 400)PUTiDELETE: actualitzar estoc i esborrar- Provar amb
curl - Provar amb el test client de pytest
- Món real: versionat
/api/v1i paginació - 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/<titol><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 dataclassLlibreen un diccionari:{"titol": "L'Odissea", "preu": 12.5, "estoc": 4}.- La llista per comprensió (M4) ho fa per a tot el catàleg.
jsonify(...)(eljson.dumpsde M6 amb esteroides) serialitza a JSON i a més munta la resposta HTTP completa: capçaleraContent-Type: application/jsoni 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
404amb un JSON explicatiu. - La teva jerarquia d'
errors.pycobra 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:
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)), 201Desgranem les decisions:
methods=["POST"]: per defecte una ruta només accepta GET; aquí declarem el verb. La mateixa URL/api/llibresté dues vistes — GET llista, POST crea — i això és REST pur.request.get_json(silent=True)retorna el dict (M4) del cos, oNonesi 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 saltarValueErrorsi arriba"preu": "gratis", iKeyErrorsi faltatitol. 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 adades/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 dirFixa'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/v2incompatible 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/llibresretorna 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/jsonal client:request.get_json()retornaNonei el teu codi esclata més endavant. Ambsilent=True+ comprovació deNonerespons 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). Revisamethods=[...]— é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'Odisseaha de viatjar comL%27Odissea(iFaust IIcomFaust%20II), amb la URL entre cometes dobles; si no, el shell interpreta l'apòstrof o l'espai pel seu compte icurlrep una altra cosa. - Validar poc el POST: "ja validarà el client" és la trampa de 10-01 — qualsevol amb
curlesquiva el teu formulari. El backend valida sempre: tipus, rangs, duplicats, i respon 400 amb el motiu. - Capturar
Exceptiona l'errorhandler: registra gestors per a excepcions concretes (LlibreNoTrobatError). Un gestor d'Exceptionque respon 404 a tot amaga bugs autèntics que haurien de ser 500 i sortir apapyrus.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"] == 9L'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
- Introducció a Python
- Configuració de l'Entorn de Desenvolupament
- Sintaxi de Python i Tipus de Dades Bàsics
- Variables i Constants
- Entrada i Sortida Bàsica
- Entorns Virtuals i Gestió de Paquets
Mòdul 2: Estructures de Control
Mòdul 3: Funcions i Mòduls
- Definició de Funcions
- Arguments de Funció
- Funcions Lambda
- Mòduls i Paquets
- Visió General de la Biblioteca Estàndard
Mòdul 4: Estructures de Dades
Mòdul 5: Programació Orientada a Objectes
Mòdul 6: Gestió de Fitxers
- Lectura i Escriptura de Fitxers
- Treball amb Fitxers CSV
- Gestió de Dades JSON
- Operacions amb Fitxers i Directoris
Mòdul 7: Gestió d'Errors i Excepcions
- Introducció a les Excepcions
- Gestió d'Excepcions
- Llançament d'Excepcions
- Excepcions Personalitzades
- Bones Pràctiques i Registre d'Errors amb logging
Mòdul 8: Temes Avançats
- Anotacions de Tipus (type hints)
- Decoradors
- Generadors
- Gestors de Context
- Concurrència: Fils i Processos
- Asyncio per a la Programació Asíncrona
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb unittest
- Proves amb pytest
- Desenvolupament Guiat per Proves
- Tècniques de Depuració
- Ús de pdb per a la Depuració
Mòdul 10: Desenvolupament Web amb Python
- Introducció al Desenvolupament Web
- Fonaments del Framework Flask
- Construcció d'APIs REST amb Flask
- Introducció a Django
- Construcció d'Aplicacions Web amb Django
Mòdul 11: Ciència de Dades amb Python
- Introducció a la Ciència de Dades
- NumPy per a la Computació Numèrica
- Pandas per a la Manipulació de Dades
- Matplotlib per a la Visualització de Dades
- Introducció a l'Aprenentatge Automàtic amb scikit-learn
