Saltar a contenido

RUTAS DINÁMICAS Y EXCEPCIONES EN FLASK

Tipos en los parámetros de URL

Recordatorio: parámetros variables

Una URL puede contener partes que cambian: el código de un producto, una cantidad, un identificador. En los apuntes anteriores vimos que Flask captura esas partes con la notación <nombre>:

1
2
3
@app.route("/producto/<codigo>")
def ver_producto(codigo):
    return f"Has pedido el producto {codigo}"

Por defecto, lo que captura <codigo> es siempre texto: si pides /producto/A1, codigo vale "A1"; si pides /producto/12, codigo vale "12" (cadena, no número). Esto es suficiente cuando la URL lleva un código o un nombre, pero no cuando esperas un número para operar con él.

Converters de Flask

Un converter es un validador de tipo que Flask aplica al segmento de URL antes de llamar a tu función. Se escribe con la sintaxis <tipo:nombre>. Si el valor no cumple el tipo, Flask responde con un 404 automáticamente y tu función ni siquiera se ejecuta.

1
2
3
@app.route("/comprar/<int:cantidad>")
def comprar(cantidad):
    return f"Compras {cantidad} unidades"  ## cantidad ya es int

Flask ofrece cinco converters integrados:

Converter Qué acepta Tipo Python Uso típico
<string:x> Texto sin / (converter por defecto) str Códigos, nombres, slugs
<int:x> Números enteros sin signo int Cantidades, identificadores
<float:x> Números decimales con punto float Precios, porcentajes
<path:x> Texto que puede incluir / str Rutas de ficheros anidadas
<uuid:x> Identificadores UUID UUID Identificadores únicos

Cuando se omite el tipo (<codigo>), Flask aplica string por defecto.

Las regex detrás de los converters

Cada converter tiene por dentro una expresión regular (regex) — una fórmula que describe qué forma tiene que tener el texto para ser aceptado. Si no conoces las expresiones regulares, basta con saber esto: son patrones que describen qué caracteres están permitidos y en qué orden.

Converter Regex interna Qué significa
<int:x> \d+ Uno o más dígitos (0-9). No acepta signo ni decimales.
<float:x> \d+\.\d+ Dígitos, punto, dígitos. No acepta enteros puros ni signo.
<string:x> [^/]+ Cualquier carácter salvo /.

Esto tiene consecuencias sorprendentes cuando el usuario teclea la URL:

URL solicitada Resultado Por qué
/comprar/3 Llega al route con cantidad=3 Matchea \d+
/comprar/tres 404 tres no son dígitos
/comprar/-1 404 El signo - no es un dígito
/insertar/1.5 Llega con cantidad=1.5 Matchea \d+\.\d+
/insertar/1 404 <float> exige punto decimal
/insertar/-1.5 404 El signo - tampoco es un dígito

Flask filtra antes que tu código

Si el converter rechaza el valor, tu función de vista no se llega a ejecutar. Flask devuelve un 404 directamente. Esto significa que los try/except ValueError que pongas dentro de la función nunca se disparan para valores inválidos de tipo — solo se disparan si el dominio rechaza el valor una vez convertido.

Dónde termina Flask y empieza el dominio

Los converters solo validan el formato. La validación de negocio (que el precio sea positivo, que la cantidad sea razonable, que el código exista) sigue en el dominio. Flask filtra lo que no cumple el tipo; el dominio filtra lo que no cumple las reglas.

1
2
3
4
5
6
7
@app.route("/insertar/<float:cantidad>")
def insertar(cantidad):
    try:
        servicio.insertar_dinero(cantidad)   ## lanza ValueError si es 0 o negativo
        return f"Saldo: {servicio.saldo():.2f} EUR"
    except ValueError as e:
        return str(e), 400

En este ejemplo, cantidad=0.0 sí llega al dominio (matchea \d+\.\d+). El try/except captura el ValueError que lanza insertar_dinero porque la cantidad no es positiva. En cambio, cantidad=-1.5 nunca llega — Flask responde 404 antes.

Mala práctica: validar el tipo en el dominio

## Incorrecto: el dominio no debe preocuparse del formato de la URL
@app.route("/insertar/<cantidad>")   ## string por defecto
def insertar(cantidad):
    try:
        cantidad_num = float(cantidad)   ## conversión manual
        servicio.insertar_dinero(cantidad_num)
    except ValueError:
        return "Formato incorrecto o cantidad inválida", 400
## Correcto
@app.route("/insertar/<float:cantidad>")
def insertar(cantidad):
    try:
        servicio.insertar_dinero(cantidad)
    except ValueError as e:
        return str(e), 400
Delegar la conversión de tipo a Flask mantiene el route más limpio y el dominio solo ve valores del tipo correcto.


Códigos HTTP como lenguaje común

Qué es un código HTTP

Cada respuesta HTTP empieza con un código de estado: un número de tres dígitos que resume qué ha pasado. El navegador lo lee antes que el cuerpo para saber si la petición ha ido bien, si hay que redirigir, si hay un error del cliente o del servidor.

HTTP/1.1 200 OK                 ← código de estado + descripción
Content-Type: text/html

<h1>Productos</h1>              ← cuerpo

Los códigos están agrupados por la primera cifra:

Rango Familia Significado
2xx Éxito La petición ha ido bien.
3xx Redirección El recurso está en otra URL; el navegador debe ir ahí.
4xx Error del cliente La petición tiene algo mal: URL, datos, permisos.
5xx Error del servidor El servidor ha fallado al procesar una petición correcta.

Los códigos que usarás en este lab

No hace falta memorizar todos los códigos. Estos son los relevantes para una app como la expendedora:

Código Nombre Cuándo lo devolvemos
200 OK Respuesta normal con contenido.
302 Found (redirección) Tras una acción, rediriges a otra URL.
400 Bad Request El cliente mandó datos inválidos (cantidad negativa, precio cero…).
404 Not Found El recurso pedido no existe (producto con código inexistente, URL no registrada).
409 Conflict La petición choca con el estado actual (alta con un código que ya existe).
500 Internal Server Error El servidor ha lanzado una excepción no controlada.

Devolver un código desde un route

Por defecto, cuando una función de vista hace return "texto", Flask añade el código 200 OK. Para devolver otro código, return acepta una tupla (cuerpo, codigo):

1
2
3
4
5
6
@app.route("/producto/<codigo>")
def ver_producto(codigo):
    try:
        return str(servicio.obtener_producto(codigo))
    except ProductoNoEncontradoError as e:
        return f"Error: {e}", 404

El código va como segundo elemento de la tupla, no como argumento con nombre. La descripción ("Not Found") la añade Flask por ti a partir del número.

Elegir el código correcto

La elección del código no es arbitraria. Herramientas automáticas (navegadores, motores de búsqueda, clientes de API) actúan según el código recibido:

  • El navegador solo cachea respuestas 200.
  • Un 302 hace que el navegador cargue automáticamente la URL indicada en la cabecera.
  • Un 404 le dice al motor de búsqueda "este enlace está roto, no lo indexes".
  • Un 500 indica un fallo del servidor: los sistemas de monitorización lo cuentan aparte de los 4xx.
Situación Código Por qué no otro
Producto con código inexistente 404 El recurso no existe. No es culpa de los datos del cliente: la URL apuntaba a algo que no está.
Precio de alta con valor cero 400 Los datos enviados son inválidos; el cliente debe reenviar con otros distintos.
Alta con código ya existente 409 La petición está bien formada, pero choca con el estado del servidor. Un 400 ocultaría que el problema es el estado, no los datos.
Excepción no capturada en un route 500 Es un fallo del servidor que no habíamos previsto.

Elige el código que comunique mejor

Si dudas entre 400 y 404, piensa: ¿el cliente mandó datos malos (400) o pidió algo que no está (404)? Si dudas entre 400 y 409, piensa: ¿el problema está en los datos (400) o en el estado del servidor (409)?


Redirección y URLs declarativas

El código 302 y redirect

Un redirect (redirección) es una respuesta HTTP que le dice al navegador: "no estoy en esta URL, ve a esta otra". El código habitual es 302 Found. El navegador, al recibirla, carga automáticamente la URL indicada sin que el usuario haga nada.

Flask tiene una función auxiliar redirect(url) que construye esa respuesta:

1
2
3
4
5
6
from flask import redirect

@app.route("/comprar")
def comprar():
    servicio.comprar()
    return redirect("/productos")   ## el navegador carga /productos

El problema de los strings hardcodeados

Escribir la URL a mano (como "/productos" arriba) funciona, pero se rompe en cuanto cambiamos la ruta. Imagina que decidimos renombrar /productos a /catalogo: tendríamos que buscar y sustituir cada redirect("/productos") repartido por el código. Es error-prono y fácil de olvidar.

url_for y el desacoplamiento

url_for() construye una URL a partir del nombre de la función de vista, no del string de la URL. Si cambias la ruta del decorador, url_for sigue devolviendo la URL correcta.

1
2
3
4
5
6
from flask import redirect, url_for

@app.route("/comprar")
def comprar():
    servicio.comprar()
    return redirect(url_for("listar_productos"))

Para rutas con parámetros, se pasan como argumentos con nombre:

1
2
3
4
5
6
7
@app.route("/producto/<codigo>")
def ver_producto(codigo): ...

@app.route("/reponer/<codigo>/<int:unidades>")
def reponer(codigo, unidades):
    servicio.reponer(codigo, unidades)
    return redirect(url_for("ver_producto", codigo=codigo))

El primer argumento de url_for es el nombre de la función Python, no la URL. Esto desacopla el código de las URLs concretas: si mañana cambias @app.route("/producto/<codigo>") por @app.route("/item/<codigo>"), todas las llamadas a url_for("ver_producto", …) siguen funcionando.

El patrón "actúa → redirige"

Cuando un route modifica el estado (comprar, agregar, eliminar), conviene redirigir al acabar en lugar de devolver directamente el mensaje de éxito. Así evitas un problema clásico: si el usuario pulsa recargar en el navegador, la última petición se reenvía, y si esa petición era una acción destructiva (comprar, eliminar) se repite sin querer.

1
2
3
4
5
6
7
@app.route("/agregar/<codigo>/<nombre>/<float:precio>/<int:cantidad>")
def agregar(codigo, nombre, precio, cantidad):
    try:
        servicio.agregar_producto(codigo, nombre, precio, cantidad)
        return redirect(url_for("ver_producto", codigo=codigo))
    except ProductoYaExisteError as e:
        return str(e), 409

Tras el redirect, el navegador queda en /producto/<codigo>. Si el usuario pulsa recargar, lo que se repite es la consulta al detalle, no el alta. Es la consulta la que se vuelve a ejecutar, no la acción.

Mala práctica: devolver el resultado de la acción como página final

## Incorrecto: si el usuario recarga, repite el alta → 409
@app.route("/agregar/<codigo>/...")
def agregar(codigo, ...):
    servicio.agregar_producto(codigo, ...)
    return f"<h1>Producto {codigo} añadido</h1>"
## Correcto
@app.route("/agregar/<codigo>/...")
def agregar(codigo, ...):
    servicio.agregar_producto(codigo, ...)
    return redirect(url_for("ver_producto", codigo=codigo))


Estado de la aplicación en el servidor

El servicio global

En la expendedora, creamos un único objeto servicio al arrancar app.py y lo usamos desde todos los routes:

1
2
3
4
5
6
7
8
servicio = crear_servicio_sqlite()

app = Flask(__name__)

@app.route("/seleccionar/<codigo>")
def seleccionar(codigo):
    servicio.seleccionar(codigo)
    ...

Ese servicio vive en memoria mientras el proceso Python esté corriendo. Es la misma instancia que atiende a todas las peticiones. Su estado interno (_saldo, _seleccion) se acumula entre peticiones.

Máquina de estados en el servidor

La máquina expendedora es una máquina de estados: no puedes comprar sin antes seleccionar un producto e insertar dinero. El orden importa.

Al exponer esto en Flask, cada route es una transición de estado, no una transacción completa. El flujo de compra queda repartido en varias peticiones HTTP:

GET /seleccionar/A1       → _seleccion = Item(A1)
GET /insertar/2.0         → _saldo += 2.0
GET /comprar              → compra con _seleccion y _saldo, reinicia

Entre una petición y la siguiente pueden pasar segundos o minutos. El navegador no guarda el estado; lo guarda el servidor dentro del objeto servicio.

Problema 1: estado compartido entre usuarios

Como servicio es uno solo, si dos navegadores apuntan al mismo servidor a la vez, se pisan el estado. El usuario A hace /seleccionar/A1, y antes de que pulse /comprar el usuario B hace /seleccionar/B2 — la selección queda como B2 para todos. Cuando A pulse /comprar, comprará B2, no lo que pidió.

Esto no es un fallo del dominio: el dominio está diseñado para un usuario. El fallo es asumir que ese único estado global puede servir a cualquier cantidad de clientes.

Problema 2: estado perdido al reiniciar

El estado vive en la RAM del proceso. Si detienes el servidor con Ctrl+C y lo vuelves a arrancar, el _saldo y la _seleccion vuelven a cero. Lo único que sobrevive es lo que hay en disco: la base de datos SQLite con los productos.

Hacia dónde vamos

Estos dos problemas no se resuelven en este lab; requieren herramientas que aún no hemos visto:

  • Persistencia del estado en fichero JSON. Si guardamos _saldo y _seleccion en un fichero tras cada operación, sobreviven al reinicio. Conecta con los apuntes de UT3 sobre ficheros y con la serialización JSON.
  • Sesiones HTTP (flask.session). Cada navegador obtiene una "cookie" que identifica su propio estado en el servidor. Resuelve el problema de usuarios concurrentes. Lo veremos cuando introduzcamos formularios.

Por ahora basta con reconocer las limitaciones: la expendedora web, tal como está, es monousuario y volátil. Es honesto nombrarlo.


Parámetros en URL vs formularios HTML

Los límites de la URL como entrada de datos

Hasta ahora, todas las entradas del usuario viajan en la URL: /agregar/X1/Zumo/1.5/10. Esto funciona para probar routes, pero tiene problemas serios para un usuario real.

Legibilidad

Una URL como /agregar_descuento/X1/Zumo/1.5/10/20.0 es ilegible. El orden de los parámetros es arbitrario y no se indica qué significa cada valor. Un error de orden (poner el porcentaje donde va la cantidad) se acepta sin avisar.

Caracteres especiales

Los nombres con espacios, acentos o barras no se pueden poner tal cual en una URL: "Zumo de naranja" tiene que pasarse como "Zumo%20de%20naranja" (URL-encoding). El usuario no va a escribir eso a mano.

El problema del separador decimal

Aquí entra un concepto del sistema operativo llamado locale. El locale es el conjunto de convenciones culturales que un sistema aplica: idioma de los mensajes, formato de las fechas, separador decimal, símbolo de moneda. Los locales se nombran con códigos como es_ES (español de España), en_US (inglés de EE. UU.) o fr_FR (francés de Francia).

El detalle que nos afecta: en es_ES el separador decimal es la coma (1,5), mientras que en en_US y en la mayoría de lenguajes de programación es el punto (1.5). Flask —como todo Python— espera siempre el punto porque es la convención del código, independientemente del idioma del sistema operativo.

Resultado: un usuario español que teclee /agregar/X1/Zumo/1,5/10 obtendrá un 404 misterioso. Desde su punto de vista, "1,5" es un número válido; desde el punto de vista del converter de Flask, no matchea \d+\.\d+.

Hacia los formularios HTML

En un próximo lab pasaremos a recoger los datos con formularios HTML (<form>). Cada campo del formulario tiene su propia casilla etiquetada, el navegador se encarga del encoding y los datos viajan en el cuerpo de la petición, no en la URL. Además, los formularios permiten validación en el cliente y una experiencia de usuario mucho más natural.

Aspecto Parámetros en URL Formularios HTML
Legibilidad Baja: orden y separadores frágiles Alta: cada campo etiquetado
Caracteres especiales Hay que codificar manualmente Gestionado por el navegador
Separador decimal Obligatorio . El <input type="number"> abstrae el formato
Aptos para producción Solo para pruebas y lecturas simples

Por eso usar parámetros en la URL para lecturas (/producto/A1) es razonable, pero usarlos para altas (/agregar/X1/Zumo/1.5/10) es una solución provisional que reemplazaremos en el siguiente lab.

Mala práctica: rutas con decenas de parámetros en la URL

## Incorrecto: URL imposible de construir a mano
@app.route("/editar/<codigo>/<nombre>/<float:precio>/<int:cantidad>/<float:descuento>/<categoria>")
def editar(codigo, nombre, precio, cantidad, descuento, categoria): ...
## Correcto (adelanto del lab a5)
@app.route("/editar/<codigo>", methods=["POST"])
def editar(codigo):
    nombre = request.form["nombre"]
    precio = float(request.form["precio"])
    ...
La URL identifica el recurso; los datos van en el cuerpo del formulario.