LABS · JULIÁN LÓPEZ JIMÉNEZ · RECURSO GRATUITO
Cinco retos con Python real: respuestas JSON, rutas, datos válidos y un endpoint que distingue éxito, ruta ausente y método incorrecto. Sin cuenta ni tarjeta.
Qué necesitas: Fundamentos de funciones, diccionarios y excepciones de Python. Navegador con JavaScript/WebAssembly. Ordenador con teclado recomendado. Las peticiones de práctica no salen del laboratorio; la carga inicial requiere conexión con Labs.
Tu resultado: Un endpoint pequeño con respuestas y errores comprobados.
Una API no devuelve solo un diccionario: necesita estado HTTP, cabeceras y bytes. En este curso representarás esa respuesta con una tupla (estado, cabeceras, cuerpo). json.dumps convierte datos en texto y encode("utf-8") lo convierte en bytes. Content-Type explica el formato; Content-Length cuenta bytes, no letras. Un 204 representa éxito sin contenido: no lleva JSON ni Content-Length. El apoyo cabeceras(extras) valida cabeceras y devuelve una copia con no-store y nosniff; léelo en apoyo.py y reutilízalo. HTTPStatus(status) valida que el estado exista. No construyas HTTP concatenando líneas: el adaptador y el servidor se encargarán del protocolo. Define respuesta(status,datos=None,extras=None): estado HTTP final conocido (>=200), cabeceras y JSON UTF-8 estable con sort_keys=True/ensure_ascii=False/allow_nan=False. Cache-Control no-store y nosniff. En 204, 205 y 304 cuerpo vacío sin Content-Type/Content-Length; la API utilizará 204. Extras no puede inyectar saltos, valores no ASCII ni sustituir tipo, longitud, política de caché/protección o introducir cabeceras de conexión. No imprime ni modifica extras.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
from apoyo import cabeceras
def respuesta(status, datos=None, extras=None):
# Implementa el contrato.
pass
Devuelve una tupla; las cabeceras son un diccionario. → Codifica JSON antes de medir su longitud. → Reutiliza cabeceras(extras) antes de serializar y separa los estados sin cuerpo.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
from apoyo import cabeceras
def respuesta(status, datos=None, extras=None):
if type(status) is not int or status < 200:
raise ValueError('Se necesita un estado HTTP final')
HTTPStatus(status) # ValueError si el estado no está registrado.
headers = cabeceras(extras)
if status in (204, 205, 304):
return status, headers, b''
cuerpo = json.dumps(datos, ensure_ascii=False, sort_keys=True, allow_nan=False).encode('utf-8')
headers.update({'Content-Type': 'application/json; charset=utf-8', 'Content-Length': str(len(cuerpo))})
return status, headers, cuerpo
La letra ó ocupa más de un byte en UTF-8: len(texto) no sirve para Content-Length. Un 204 no se representa con {}. Los extras se copian; no se devuelve el diccionario del llamante.
Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.
TOKEN = 'token-local-ficticio'
MAX_BODY = 4096
class Fallo(ValueError):
def __init__(self, status, codigo):
self.status, self.codigo = status, codigo
super().__init__(codigo)
Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
def cabeceras(extras):
headers = {'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
reservadas = {'content-type', 'content-length', 'transfer-encoding', 'connection', 'keep-alive', 'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate'}
for nombre, valor in (extras or {}).items():
if (not isinstance(nombre, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9-]*', nombre)
or not isinstance(valor, str) or any(ord(c) < 32 or ord(c) > 126 for c in valor)
or nombre.lower() in {k.lower() for k in headers} | reservadas):
raise ValueError('Cabecera de respuesta inválida')
headers[nombre] = valor
return headers
Comprueba: Compara un título ASCII y otro con ñ. Predice primero la longitud de sus cuerpos.
Trabajaremos con /tareas como colección, /tareas/1 como recurso y /salud como comprobación del proceso. Los verbos describen operaciones; evita rutas como /borrarTarea. urlsplit separa path y consulta. Aquí la API acepta rutas locales sin host ni fragmento. Los IDs son decimales positivos canónicos de hasta nueve cifras: /01, /0 y /-1 no son rutas de detalle válidas. La consulta se conserva como datos, no se ejecuta. Define ruta(destino): devuelve (salud|coleccion|detalle|ausente,id_o_None,consulta). Solo /salud, /tareas y /tareas/[1-9][0-9]{0,8}. Hasta 2.048 caracteres, sin origen externo/fragmento ni controles o espacios sin codificar. Consulta de hasta cuatro campos con un valor por clave; consulta mal formada o repetida produce Fallo 400. Una ruta desconocida devuelve ausente.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
def ruta(destino):
# Implementa el contrato.
pass
Separa URL y consulta antes de comparar el path. → Usa fullmatch, no una coincidencia parcial del ID. → parse_qs devuelve listas: rechaza las claves con dos valores antes de aplanarlas.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
def ruta(destino):
if not isinstance(destino, str) or len(destino) > 2048 or any(ord(c) <= 32 or ord(c) == 127 for c in destino):
raise Fallo(400, 'destino-invalido')
try:
partes = urlsplit(destino)
except ValueError:
raise Fallo(400, 'destino-invalido') from None
if partes.scheme or partes.netloc or partes.fragment or not destino.startswith('/'):
raise Fallo(400, 'destino-invalido')
try:
grupos = parse_qs(partes.query, keep_blank_values=True, strict_parsing=True, max_num_fields=4)
except ValueError:
raise Fallo(400, 'consulta-invalida') from None
if any(len(valores) != 1 for valores in grupos.values()):
raise Fallo(400, 'consulta-repetida')
consulta = {nombre: valores[0] for nombre, valores in grupos.items()}
if partes.path == '/salud':
return 'salud', None, consulta
if partes.path == '/tareas':
return 'coleccion', None, consulta
m = re.fullmatch(r'/tareas/([1-9][0-9]{0,8})', partes.path)
return ('detalle', int(m[1]), consulta) if m else ('ausente', None, consulta)
La ruta identifica un recurso, pero todavía no decide si existe ni si el método está permitido. Esas decisiones tendrán estados diferentes: 404 y 405.
Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.
TOKEN = 'token-local-ficticio'
MAX_BODY = 4096
class Fallo(ValueError):
def __init__(self, status, codigo):
self.status, self.codigo = status, codigo
super().__init__(codigo)
Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
Comprueba: Prueba /tareas/2?offset=0 y explica por qué reconocer una ruta no valida todavía sus parámetros.
Los cuerpos llegan como bytes. Primero limita su tamaño, después comprueba el tipo y finalmente decodifica UTF-8. application/json y su variante con charset=utf-8 son los tipos de este laboratorio. JSON válido puede ser una lista, null o un objeto que no tiene los campos de una tarea. Esa validación llegará después. Rechazamos claves repetidas y NaN/Infinity para que dos clientes no interpreten el mismo mensaje de forma distinta. Define leer_json(cuerpo,tipo,*,limite=MAX_BODY): máximo 4.096 bytes por defecto; el cliente podrá pasar 65.536 para respuestas. Tipo application/json opcionalmente con charset=utf-8, sin otros parámetros. Rechaza tipo con Fallo 415 y exceso con 413; bytes inválidos, JSON incorrecto, duplicados y constantes no JSON con 400. Devuelve el valor JSON sin validar aún el dominio.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
from apoyo import objeto_unico, constante_invalida
def leer_json(cuerpo, tipo, *, limite=MAX_BODY):
# Implementa el contrato.
pass
len(cuerpo) mide bytes antes de decodificar. → Los helpers objeto_unico y constante_invalida están en apoyo.py. → No conviertas un error en {}: conservarías una apariencia de petición válida.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
from apoyo import objeto_unico, constante_invalida
def leer_json(cuerpo, tipo, *, limite=MAX_BODY):
if not isinstance(cuerpo, bytes):
raise Fallo(400, 'cuerpo-invalido')
if len(cuerpo) > limite:
raise Fallo(413, 'cuerpo-demasiado-grande')
partes = [p.strip().lower() for p in (tipo or '').split(';')]
if partes[0] != 'application/json' or any(p != 'charset=utf-8' for p in partes[1:]):
raise Fallo(415, 'tipo-no-admitido')
try:
return json.loads(cuerpo.decode('utf-8'), object_pairs_hook=objeto_unico, parse_constant=constante_invalida)
except (UnicodeError, ValueError, RecursionError):
raise Fallo(400, 'json-invalido') from None
Un 400 explica un problema de representación; un 415 explica que el formato no está admitido. La forma y los tipos de una tarea producirán otro error, 422.
Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.
TOKEN = 'token-local-ficticio'
MAX_BODY = 4096
class Fallo(ValueError):
def __init__(self, status, codigo):
self.status, self.codigo = status, codigo
super().__init__(codigo)
Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
def objeto_unico(pares):
objeto = {}
for clave, valor in pares:
if clave in objeto:
raise ValueError('Clave JSON repetida')
objeto[clave] = valor
return objeto
def constante_invalida(valor):
raise ValueError('Constante fuera de JSON')
Comprueba: Construye un cuerpo de 4.096 y otro de 4.097 bytes usando espacios JSON válidos.
La tarea de entrada solo contiene titulo y hecho; id y version los decide el servidor. El título es texto no vacío tras strip y tiene como máximo 80 caracteres; hecho es un bool exacto, no el texto "false" ni el entero 0. En creación hecho puede omitirse y vale False. Un reemplazo completo exige ambos campos. La validación construye un objeto nuevo y rechaza controles y Unicode que no pueda codificarse en UTF-8. Define validar_tarea(datos,*,completa=False): dict nuevo con titulo normalizado y hecho bool exacto. Solo titulo/hecho; titulo obligatorio, str de 1–80 caracteres tras strip, sin controles ni surrogates inválidos. Completa=True exige ambas claves. Datos inválidos producen Fallo 422 sin mutar la entrada.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
def validar_tarea(datos, *, completa=False):
# Implementa el contrato.
pass
Comprueba las claves antes de leer sus valores. → type(hecho) is bool evita coerciones sorprendentes. → Devuelve otro diccionario; no uses pop o strip asignando sobre la entrada.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
def validar_tarea(datos, *, completa=False):
if not isinstance(datos, dict) or set(datos) - {'titulo', 'hecho'} or 'titulo' not in datos:
raise Fallo(422, 'tarea-invalida')
if completa and set(datos) != {'titulo', 'hecho'}:
raise Fallo(422, 'tarea-incompleta')
titulo, hecho = datos['titulo'], datos.get('hecho', False)
if not isinstance(titulo, str) or not 1 <= len(titulo.strip()) <= 80 or type(hecho) is not bool:
raise Fallo(422, 'tarea-invalida')
try:
titulo.encode('utf-8')
except UnicodeError:
raise Fallo(422, 'titulo-invalido') from None
if any(ord(c) < 32 or ord(c) == 127 for c in titulo):
raise Fallo(422, 'titulo-invalido')
return {'titulo': titulo.strip(), 'hecho': hecho}
La normalización tiene una política explícita. No convierte un valor inválido en algo que parezca correcto ni permite al cliente elegir su ID.
Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.
TOKEN = 'token-local-ficticio'
MAX_BODY = 4096
class Fallo(ValueError):
def __init__(self, status, codigo):
self.status, self.codigo = status, codigo
super().__init__(codigo)
Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
Comprueba: Predice qué ocurre con espacios, 80 caracteres y hecho=None.
Un endpoint combina una ruta, métodos permitidos y una respuesta. Este ejemplo no abre un puerto: recibe método y destino como argumentos y devuelve la misma tupla que usará el servidor. Puedes explorar con print(saludo("GET", "/saludo")) y retirar esa llamada antes de Comprobar. Un recurso que no existe es 404; un método no admitido sobre el recurso existente es 405. Define saludo(metodo,destino): GET /saludo produce 200 y {mensaje:"Hola, API"}. Otra ruta produce 404 y error ruta-ausente; otro método en /saludo produce 405 con Allow: GET y error metodo-no-admitido. Delega la representación en respuesta. No imprime.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
from apoyo import cabeceras, respuesta
def saludo(metodo, destino):
# Implementa el contrato.
pass
Primero compara la ruta. → La cabecera Allow pertenece al 405, no al 404. → Usa respuesta para mantener tipo, bytes y longitud coherentes.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
from apoyo import cabeceras, respuesta
def saludo(metodo, destino):
if destino != '/saludo':
return respuesta(404, {'error': {'codigo': 'ruta-ausente'}})
if metodo != 'GET':
return respuesta(405, {'error': {'codigo': 'metodo-no-admitido'}}, {'Allow': 'GET'})
return respuesta(200, {'mensaje': 'Hola, API'})
Has construido un contrato que se puede contrastar. El curso lo extenderá a estado, CRUD, token, condiciones y un servidor HTTP local.
Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.
TOKEN = 'token-local-ficticio'
MAX_BODY = 4096
class Fallo(ValueError):
def __init__(self, status, codigo):
self.status, self.codigo = status, codigo
super().__init__(codigo)
Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
def cabeceras(extras):
headers = {'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
reservadas = {'content-type', 'content-length', 'transfer-encoding', 'connection', 'keep-alive', 'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate'}
for nombre, valor in (extras or {}).items():
if (not isinstance(nombre, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9-]*', nombre)
or not isinstance(valor, str) or any(ord(c) < 32 or ord(c) > 126 for c in valor)
or nombre.lower() in {k.lower() for k in headers} | reservadas):
raise ValueError('Cabecera de respuesta inválida')
headers[nombre] = valor
return headers
def respuesta(status, datos=None, extras=None):
if type(status) is not int or status < 200:
raise ValueError('Se necesita un estado HTTP final')
HTTPStatus(status) # ValueError si el estado no está registrado.
headers = cabeceras(extras)
if status in (204, 205, 304):
return status, headers, b''
cuerpo = json.dumps(datos, ensure_ascii=False, sort_keys=True, allow_nan=False).encode('utf-8')
headers.update({'Content-Type': 'application/json; charset=utf-8', 'Content-Length': str(len(cuerpo))})
return status, headers, cuerpo
Comprueba: Predice GET /saludo/ y DELETE /saludo. Explica por qué sus estados son distintos.
Cambia el mensaje, predice otra ruta y comprueba un método no permitido sin convertir todos los resultados en 200.
import copy, hmac, io, json, re
from http import HTTPStatus
from urllib.parse import parse_qs, urlsplit
from laboratorio import Fallo, TOKEN, MAX_BODY
from apoyo import cabeceras, respuesta
def saludo(metodo, destino):
if destino != '/saludo':
return respuesta(404, {'error': {'codigo': 'ruta-ausente'}})
if metodo != 'GET':
return respuesta(405, {'error': {'codigo': 'metodo-no-admitido'}}, {'Allow': 'GET'})
return respuesta(200, {'mensaje': 'Hola, API'})
Cada ejecución empieza de cero. La guía muestra main.py, laboratorio.py y apoyo.py por separado; usa los apoyos del mismo reto. Python 3.12 o posterior para repetir fuera. No se instala un servidor con esta guía.
Repite con datos distintos antes de marcarlo como practicado. Esta es una autoevaluación, no una certificación.
Tu primera API REST añade CRUD, paginación, token ficticio, ETag/If-Match, pruebas y un servidor HTTP local. El recurso y sus soluciones siguen abiertos.
Ver el curso Tu primera API REST y su temarioFuente: Recurso original de Labs. Puedes guardar este HTML, abrirlo sin conexión o imprimirlo como PDF desde tu navegador. No guarda tus respuestas ni el progreso de tu cuenta.