La web de la lección anterior es perfecta para Luis y su navegador, pero Júlia quiere una app móvil, y una app móvil no quiere tablas HTML: quiere datos crudos que pueda pintar a su manera. Ese es el trabajo de una API REST: las mismas rutas y verbos HTTP de siempre, pero con JSON en el cuerpo — el formato que dominas desde M6. En esta lección construirás la API completa de Papyrus: listar el catálogo, consultar un libro (y traducir LibroNoEncontradoError a un 404 limpio, cerrando el círculo que abrimos en M7), dar de alta libros con validación seria, actualizar stock y borrar. La probarás desde la terminal con curl y desde pytest con el test client, y saldrás con el contrato de la API documentado como lo haría un profesional.
Contenido
- Qué es REST: recursos, verbos, sin estado
- Diseño de la API de Papyrus
GET /api/libros: la lista (conjsonifyyasdict)GET /api/libros/<titulo>: el detalle y el 404 conerrorhandlerPOST /api/libros: crear con validación (201 o 400)PUTyDELETE: actualizar stock y borrar- Probar con
curl - Probar con el test client de pytest
- Mundo real: versionado
/api/v1y paginación - El contrato de la API, documentado
Qué es REST: recursos, verbos, sin estado
REST (Representational State Transfer) no es una tecnología sino un estilo: una manera de diseñar APIs para que cualquier desarrollador las entienda sin leer el manual. Sus principios prácticos:
| Principio | Qué significa | En Papyrus |
|---|---|---|
| Todo es un recurso con URL propia | Las URLs nombran cosas (sustantivos), no acciones | /api/libros, /api/libros/Hamlet |
| Los verbos HTTP son las acciones | GET lee, POST crea, PUT actualiza, DELETE borra — nunca /api/borrarLibro |
DELETE /api/libros/Hamlet |
| Sin estado (stateless) | Cada petición es autosuficiente; el servidor no recuerda la anterior | Dos GET seguidos no dependen entre sí |
| Representaciones | El recurso viaja en un formato acordado, casi siempre JSON | {"titulo": "Hamlet", ...} |
| Códigos de estado con significado | La respuesta se autodescribe: 200, 201, 400, 404... | La tabla final de esta lección |
La ausencia de estado es la que más extraña al principio y la más valiosa: como el servidor no guarda memoria de conversaciones, cualquier servidor idéntico puede atender la siguiente petición — así se escala a millones de usuarios. El precio: todo lo que la petición necesite debe viajar en ella.
Diseño de la API de Papyrus
Antes de codificar, se diseña. El recurso es el libro; la colección, los libros. De ahí sale todo el mapa:
flowchart LR
A["/api/libros<br/>(colección)"] -->|GET| B["Listar catálogo → 200"]
A -->|POST| C["Crear libro → 201 / 400"]
D["/api/libros/<titulo><br/>(recurso)"] -->|GET| E["Detalle → 200 / 404"]
D -->|PUT| F["Actualizar stock → 200 / 400 / 404"]
D -->|DELETE| G["Borrar → 204 / 404"]
Montamos la API en el mismo app.py de 10-02 (la web HTML y la API conviven sin problema), con el prefijo /api para separarlas.
GET /api/libros: la lista (con jsonify y asdict)
from dataclasses import asdict
from flask import Flask, jsonify, request
from papyrus.almacen import cargar_catalogo, obtener_libro, guardar_catalogo
from papyrus.modelos import Libro
from papyrus.errores import LibroNoEncontradoError
app = Flask(__name__)
catalogo = cargar_catalogo("datos/catalogo.json")
@app.route("/api/libros")
def api_listar():
return jsonify([asdict(libro) for libro in catalogo.values()])Aquí se sueldan tres módulos del curso en una línea:
asdict(libro)(M5) convierte cada dataclassLibroen un diccionario:{"titulo": "La Odisea", "precio": 12.5, "stock": 4}.- La lista por comprensión (M4) lo hace para todo el catálogo.
jsonify(...)(eljson.dumpsde M6 con esteroides) serializa a JSON y además monta la respuesta HTTP completa: cabeceraContent-Type: application/jsony código 200.
La respuesta que recibirá la app de Júlia:
[
{"titulo": "La Odisea", "precio": 12.5, "stock": 4},
{"titulo": "Hamlet", "precio": 9.95, "stock": 6},
{"titulo": "El Quijote", "precio": 15.9, "stock": 8},
{"titulo": "Fausto", "precio": 21.0, "stock": 10}
]GET /api/libros/<titulo>: el detalle y el 404 con errorhandler
Para el detalle usamos obtener_libro() — la variante de M7 que lanza LibroNoEncontradoError en vez de devolver None. ¿Y quién captura esa excepción? Podríamos poner un try/except en cada vista... o decírselo a Flask una sola vez:
@app.errorhandler(LibroNoEncontradoError)
def libro_no_encontrado(error):
return jsonify({"error": str(error)}), 404
@app.route("/api/libros/<titulo>")
def api_detalle(titulo):
libro = obtener_libro(catalogo, titulo) # puede lanzar LibroNoEncontradoError
return jsonify(asdict(libro))Este es el momento en que M7 y 10-01 se dan la mano, y merece que lo saborees:
@app.errorhandler(LibroNoEncontradoError)— otro decorador de registro (08-02, van tres) — declara: "cuando cualquier vista deje escapar esta excepción, responde así".- La vista queda limpia: pide el libro y lo devuelve. El caso de error ni aparece: la excepción sube (M7: las excepciones viajan hacia arriba hasta que alguien las captura) y el manejador la traduce a
404con un JSON explicativo. - Tu jerarquía de
errores.pycobra un valor nuevo: como todas heredan deErrorPapyrus, podrías registrar un manejador genérico para la familia entera. Diseño de M7, dividendos en M10.
Respuesta para GET /api/libros/Rayuela:
con código 404 Not Found. La app de Júlia mira el código, ve 404, y muestra "libro no disponible" — sin adivinar nada.
POST /api/libros: crear con validación (201 o 400)
Crear exige recibir datos. El cliente los envía como JSON en el cuerpo y Flask los entrega con request.get_json():
@app.route("/api/libros", methods=["POST"])
def api_crear():
datos = request.get_json(silent=True)
if datos is None:
return jsonify({"error": "Se esperaba un cuerpo JSON"}), 400
try:
libro = Libro(titulo=str(datos["titulo"]),
precio=float(datos["precio"]),
stock=int(datos.get("stock", 0)))
except (KeyError, TypeError, ValueError) as exc:
return jsonify({"error": f"Datos inválidos: {exc}"}), 400
if libro.precio <= 0:
return jsonify({"error": "El precio debe ser positivo"}), 400
if libro.titulo in catalogo:
return jsonify({"error": f"'{libro.titulo}' ya existe"}), 400
catalogo[libro.titulo] = libro
guardar_catalogo(catalogo, "datos/catalogo.json")
return jsonify(asdict(libro)), 201Desmenucemos las decisiones:
methods=["POST"]: por defecto una ruta solo acepta GET; aquí declaramos el verbo. La misma URL/api/librostiene dos vistas — GET lista, POST crea — y eso es REST puro.request.get_json(silent=True)devuelve el dict (M4) del cuerpo, oNonesi el cuerpo no es JSON válido — que convertimos en un 400 educado en lugar de un 500 vergonzante.- La dataclass valida la forma: construir
Libro(...)con conversiones explícitas (float,int) hace saltarValueErrorsi llega"precio": "gratis", yKeyErrorsi faltatitulo. Capturamos la terna y respondemos 400 con el motivo — un error útil ahorra horas a quien usa tu API (la lección de mensajes de error de M7, ahora de cara al público). - Validaciones de negocio (precio positivo, duplicados) → también 400.
- Éxito →
201 Created(no 200: creado es información) y el libro recién creado como cuerpo, para que el cliente confirme cómo quedó.guardar_catalogo(M6) persiste el cambio endatos/catalogo.json.
PUT y DELETE: actualizar stock y borrar
Con los patrones ya vistos, las dos operaciones restantes salen solas:
@app.route("/api/libros/<titulo>", methods=["PUT"])
def api_actualizar(titulo):
libro = obtener_libro(catalogo, titulo) # 404 automático si no existe
datos = request.get_json(silent=True) or {}
if "stock" not in datos:
return jsonify({"error": "Falta el campo 'stock'"}), 400
try:
nuevo_stock = int(datos["stock"])
except (TypeError, ValueError):
return jsonify({"error": "'stock' debe ser un entero"}), 400
if nuevo_stock < 0:
return jsonify({"error": "El stock no puede ser negativo"}), 400
libro.stock = nuevo_stock
guardar_catalogo(catalogo, "datos/catalogo.json")
return jsonify(asdict(libro)) # 200 con el estado final
@app.route("/api/libros/<titulo>", methods=["DELETE"])
def api_borrar(titulo):
obtener_libro(catalogo, titulo) # valida existencia → 404
del catalogo[titulo]
guardar_catalogo(catalogo, "datos/catalogo.json")
return "", 204 # sin contenido: no hay nada que decirFíjate en el rendimiento que da el errorhandler: ambas vistas empiezan con obtener_libro y no gestionan el caso "no existe" — la excepción de M7 y el manejador se ocupan. El 204 No Content del DELETE es el código canónico para "hecho, y no hay cuerpo que devolver".
Probar con curl
curl es el cliente HTTP de terminal universal: con él tocas tu API sin escribir ni una línea de frontend. Con el servidor corriendo (flask --app app run --debug):
# Listar el catálogo
curl http://127.0.0.1:5000/api/libros
# Detalle (URL con espacios: entre comillas y codificados como %20)
curl "http://127.0.0.1:5000/api/libros/La%20Odisea"
# Un 404 con mensaje útil
curl -i http://127.0.0.1:5000/api/libros/Rayuela
# Crear un libro (POST con cuerpo JSON)
curl -X POST http://127.0.0.1:5000/api/libros \
-H "Content-Type: application/json" \
-d '{"titulo": "Fausto II", "precio": 18.50, "stock": 3}'
# Actualizar stock (PUT)
curl -X PUT http://127.0.0.1:5000/api/libros/Hamlet \
-H "Content-Type: application/json" \
-d '{"stock": 9}'
# Borrar (DELETE)
curl -X DELETE "http://127.0.0.1:5000/api/libros/Fausto%20II"Chuleta: -X fija el verbo, -H añade una cabecera (declarar Content-Type: application/json es obligatorio para que get_json() funcione sin sustos), -d es el cuerpo, -i muestra también las cabeceras y el código de estado de la respuesta. Ejecuta los seis y observa los códigos: 200, 200, 404, 201, 200, 204 — tu API dice la verdad.
Probar con el test client de pytest
Y como todo en Papyrus desde M9: si no tiene test, no existe. Flask incluye un test client que simula peticiones sin levantar servidor, y encaja directo en tu suite:
# tests/test_api.py
import pytest
from app import app
@pytest.fixture
def cliente():
app.config["TESTING"] = True
return app.test_client()
def test_listar_devuelve_el_catalogo(cliente):
respuesta = cliente.get("/api/libros")
assert respuesta.status_code == 200
titulos = [libro["titulo"] for libro in respuesta.get_json()]
assert "La Odisea" in titulos
def test_libro_inexistente_da_404_con_mensaje(cliente):
respuesta = cliente.get("/api/libros/Rayuela")
assert respuesta.status_code == 404
assert "Rayuela" in respuesta.get_json()["error"]Las piezas son las de M9 — fixture, asserts, pytest en verde — aplicadas a HTTP: cliente.get(...) hace la petición en memoria y respuesta.get_json() deshace el JSON. No profundizamos más (M9 ya te dio el oficio), pero que quede la semilla: los contratos de tu API — códigos incluidos — se protegen con regresiones igual que los precios de socio.
Mundo real: versionado /api/v1 y paginación
Dos prácticas que verás en toda API profesional y que aquí solo dejamos nombradas, con honestidad:
- Versionado: las APIs públicas prefijan la versión —
/api/v1/libros— para poder lanzar una/api/v2incompatible sin romper las apps que ya usan la v1. Con cuatro libros y un cliente (Júlia) no lo necesitamos; con clientes de verdad, es lo primero que se decide. - Paginación:
GET /api/librosdevuelve el catálogo entero. Con 4 libros, perfecto; con 40.000, sería un regalo envenenado. Las APIs reales devuelven páginas (/api/libros?pagina=2&por_pagina=50) y metadatos de navegación. Recuérdalo el día que tu colección crezca.
El contrato de la API, documentado
Toda API merece su contrato por escrito. Este es el de Papyrus v1 — la tabla que le pasarías a quien programe la app de Júlia:
| Ruta | Verbo | Cuerpo de entrada | Éxito | Errores |
|---|---|---|---|---|
/api/libros |
GET | — | 200 + lista de libros |
— |
/api/libros |
POST | {"titulo", "precio", "stock"?} |
201 + libro creado |
400 datos inválidos o duplicado |
/api/libros/<titulo> |
GET | — | 200 + libro |
404 no existe |
/api/libros/<titulo> |
PUT | {"stock": entero ≥ 0} |
200 + libro actualizado |
400 stock inválido, 404 no existe |
/api/libros/<titulo> |
DELETE | — | 204 sin cuerpo |
404 no existe |
Todos los errores comparten formato: {"error": "mensaje legible"}. Esa consistencia también es parte del contrato.
Errores Comunes y Consejos
- Olvidar
Content-Type: application/jsonen el cliente:request.get_json()devuelveNoney tu código estalla más adelante. Consilent=True+ comprobación deNonerespondes un 400 claro en vez de un 500 misterioso. 405 Method Not Allowed: hiciste POST a una ruta que solo declara GET (o al revés). Revisamethods=[...]— es el olvido más frecuente de la lección.- Devolver 200 en los errores: el pecado capital de 10-01, ahora con consecuencias reales — los clientes automatizados miran el código antes que el cuerpo. Código y cuerpo deben contar la misma historia.
- Espacios en las URLs de
curl:La Odiseadebe viajar comoLa%20Odiseay la URL entre comillas; si no,curlcree que le pasas dos argumentos. - Validar poco el POST: "ya validará el cliente" es la trampa de 10-01 — cualquiera con
curlesquiva tu formulario. El backend valida siempre: tipo, rangos, duplicados, y responde 400 con el motivo. - Capturar
Exceptionen elerrorhandler: registra manejadores para excepciones concretas (LibroNoEncontradoError). Un manejador deExceptionque responde 404 a todo esconde bugs auténticos que deberían ser 500 y salir enpapyrus.log.
Ejercicios
Ejercicio 1: endpoint de venta
Añade POST /api/libros/<titulo>/venta que reciba {"unidades": n, "socio": bool} y llame al vender() de M7 (devuelve el importe y descuenta stock). Responde 200 con {"titulo", "unidades", "importe"}. StockInsuficienteError debe convertirse en 409 Conflict (el código para "la petición es válida pero choca con el estado actual") mediante un errorhandler nuevo — sin try/except en la vista.
Ejercicio 2: contrato bajo test
Escribe dos tests con el test client: (a) POST /api/libros sin el campo precio responde 400 y el mensaje menciona el problema; (b) tras un PUT que fija el stock de Hamlet a 9, un GET de detalle devuelve stock == 9.
Ejercicio 3: filtro por disponibilidad
Amplía GET /api/libros con el parámetro opcional ?disponibles=si que filtre con hay_stock() (M5). Sin el parámetro, comportamiento actual intacto — que un cambio no rompa el contrato existente es media profesión.
Soluciones
Ejercicio 1:
from papyrus.errores import StockInsuficienteError
from papyrus.almacen import vender
@app.errorhandler(StockInsuficienteError)
def sin_stock(error):
return jsonify({"error": str(error)}), 409
@app.route("/api/libros/<titulo>/venta", methods=["POST"])
def api_vender(titulo):
datos = request.get_json(silent=True) or {}
unidades = int(datos.get("unidades", 1))
importe = vender(catalogo, titulo, unidades) # 404 o 409 automáticos
guardar_catalogo(catalogo, "datos/catalogo.json")
return jsonify({"titulo": titulo, "unidades": unidades,
"importe": round(importe, 2)})La vista no tiene ni un try: LibroNoEncontradoError → 404 y StockInsuficienteError → 409 los resuelven los manejadores. Tus excepciones de M7 se han convertido en el sistema de errores de la API.
Ejercicio 2:
def test_post_sin_precio_da_400(cliente):
respuesta = cliente.post("/api/libros", json={"titulo": "Ilíada"})
assert respuesta.status_code == 400
assert "precio" in respuesta.get_json()["error"]
def test_put_actualiza_el_stock(cliente):
cliente.put("/api/libros/Hamlet", json={"stock": 9})
respuesta = cliente.get("/api/libros/Hamlet")
assert respuesta.get_json()["stock"] == 9El argumento json= del test client serializa el cuerpo y pone el Content-Type por ti. (Purista de M9: estos tests mutan el catálogo compartido; una fixture que lo restaure con tmp_path sería el siguiente refinamiento.)
Ejercicio 3:
@app.route("/api/libros")
def api_listar():
libros = catalogo.values()
if request.args.get("disponibles") == "si":
libros = [libro for libro in libros if libro.hay_stock()]
return jsonify([asdict(libro) for libro in libros])Parámetro ausente → rama nueva ni se ejecuta: el contrato de la tabla final sigue siendo cierto palabra por palabra.
Conclusión
Papyrus ya habla los dos idiomas de la web: HTML para personas (10-02) y JSON para programas. La API cubre el ciclo completo del recurso libro — listar, detallar, crear, actualizar, borrar — con los verbos y códigos correctos, y lo mejor es cuánto curso viejo sostiene lo nuevo: asdict() de M5 alimenta a jsonify, el JSON de M6 es el cuerpo de cada respuesta, y LibroNoEncontradoError de M7 se traduce en 404 con un errorhandler de una sola pieza — el decorador de 08-02 trabajando otra vez. Probaste el contrato con curl desde fuera y con el test client de pytest desde dentro, y dejaste documentada la tabla que cualquier cliente necesita. Flask te ha enseñado las piezas una a una: ruta, vista, plantilla, JSON, error. La pregunta natural es qué pasa cuando el proyecto crece y quieres que alguien te dé hechas las piezas repetitivas — persistencia real, panel de administración, formularios con validación automática, usuarios. Esa filosofía opuesta y complementaria tiene nombre: Django, el framework con las baterías incluidas. En la próxima lección arrancamos un proyecto Django desde cero y verás tu dataclass Libro renacer como modelo con persistencia regalada.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
