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

  1. Qué es REST: recursos, verbos, sin estado
  2. Diseño de la API de Papyrus
  3. GET /api/libros: la lista (con jsonify y asdict)
  4. GET /api/libros/<titulo>: el detalle y el 404 con errorhandler
  5. POST /api/libros: crear con validación (201 o 400)
  6. PUT y DELETE: actualizar stock y borrar
  7. Probar con curl
  8. Probar con el test client de pytest
  9. Mundo real: versionado /api/v1 y paginación
  10. 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/&lt;titulo&gt;<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 dataclass Libro en un diccionario: {"titulo": "La Odisea", "precio": 12.5, "stock": 4}.
  • La lista por comprensión (M4) lo hace para todo el catálogo.
  • jsonify(...) (el json.dumps de M6 con esteroides) serializa a JSON y además monta la respuesta HTTP completa: cabecera Content-Type: application/json y 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 404 con un JSON explicativo.
  • Tu jerarquía de errores.py cobra un valor nuevo: como todas heredan de ErrorPapyrus, podrías registrar un manejador genérico para la familia entera. Diseño de M7, dividendos en M10.

Respuesta para GET /api/libros/Rayuela:

{"error": "No existe el libro 'Rayuela' en el catálogo"}

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)), 201

Desmenucemos las decisiones:

  • methods=["POST"]: por defecto una ruta solo acepta GET; aquí declaramos el verbo. La misma URL /api/libros tiene dos vistas — GET lista, POST crea — y eso es REST puro.
  • request.get_json(silent=True) devuelve el dict (M4) del cuerpo, o None si 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 saltar ValueError si llega "precio": "gratis", y KeyError si falta titulo. 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 en datos/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 decir

Fí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/v2 incompatible 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/libros devuelve 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/json en el cliente: request.get_json() devuelve None y tu código estalla más adelante. Con silent=True + comprobación de None respondes 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). Revisa methods=[...] — 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 Odisea debe viajar como La%20Odisea y la URL entre comillas; si no, curl cree que le pasas dos argumentos.
  • Validar poco el POST: "ya validará el cliente" es la trampa de 10-01 — cualquiera con curl esquiva tu formulario. El backend valida siempre: tipo, rangos, duplicados, y responde 400 con el motivo.
  • Capturar Exception en el errorhandler: registra manejadores para excepciones concretas (LibroNoEncontradoError). Un manejador de Exception que responde 404 a todo esconde bugs auténticos que deberían ser 500 y salir en papyrus.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"] == 9

El 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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados